oto/packages/flutter/oto_console/test/oto_console_test.dart

472 lines
16 KiB
Dart

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);
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 = <String, String>{
'Runners': 'No runners connected',
'Pipelines': 'No pipeline jobs',
'Executions': 'No executions',
'Artifacts': 'No artifacts',
'Settings': 'No local preferences',
};
for (final entry in sections.entries) {
await tester.tap(find.byTooltip(entry.key));
await tester.pumpAndSettle();
expect(find.text(entry.value), findsOneWidget);
}
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
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)),
),
),
);
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('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);
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);
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),
),
),
),
);
expect(find.text('Logs Preview'), findsOneWidget);
expect(find.textContaining('Starting build...'), findsOneWidget);
expect(find.text('Artifacts'), findsOneWidget);
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('OtoJobsSurface renders job create action states', (tester) async {
// --- Fresh idle form: both fields empty -> button disabled (opacity < 1.0) ---
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OtoJobsSurface(
snapshot: const OtoSurfaceSnapshot.empty(),
jobCreateState: const OtoActionViewState.idle(),
onCreateJob: (draft) {},
),
),
),
);
expect(find.textContaining('Create New Job'), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(2));
final createButton = find.ancestor(
of: find.text('Create'),
matching: find.byType(ElevatedButton),
);
expect(createButton, findsOneWidget);
// Disabled button should have opacity 0.5
final disabledButton = tester.widget<ElevatedButton>(createButton);
expect(disabledButton.onPressed, isNull);
// --- Type id -> button still disabled (name empty) ---
await tester.enterText(find.byType(TextField).at(0), 'my-job-id');
await tester.pump();
var button = tester.widget<ElevatedButton>(
find.ancestor(of: find.text('Create'), matching: find.byType(ElevatedButton)),
);
expect(button.onPressed, isNull);
// --- Type name -> button enabled ---
await tester.enterText(find.byType(TextField).at(1), 'My Job Name');
await tester.pump();
button = tester.widget<ElevatedButton>(
find.ancestor(of: find.text('Create'), matching: find.byType(ElevatedButton)),
);
expect(button.onPressed, isNotNull);
// --- Tap submit -> callback fires with correct draft ---
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');
// --- Submitting state shows spinner ---
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);
// --- Success state shows message ---
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);
// --- Failed state shows error message ---
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);
});
}