999 lines
31 KiB
Dart
999 lines
31 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);
|
|
|
|
// 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('New Item opens create intent without selecting Pipelines', (
|
|
tester,
|
|
) async {
|
|
const config = OtoConsoleConfig(
|
|
serverHttpUrl: 'http://localhost:8080',
|
|
serverWireUrl: 'ws://localhost:18080/runner',
|
|
);
|
|
final navigations = <OtoConsoleSection>[];
|
|
var newItemTaps = 0;
|
|
var showCreate = false;
|
|
|
|
await tester.pumpWidget(
|
|
StatefulBuilder(
|
|
builder: (context, setHostState) => MaterialApp(
|
|
home: OtoConsoleShell(
|
|
config: config,
|
|
overview: const OtoConsoleOverview(
|
|
config: config,
|
|
connection: onlineConnection,
|
|
),
|
|
pipelines: OtoJobsSurface(
|
|
snapshot: const OtoSurfaceSnapshot.empty(),
|
|
jobCreateState: const OtoActionViewState.idle(),
|
|
showCreateForm: showCreate,
|
|
createOnly: showCreate,
|
|
onCreateJob: (_) {},
|
|
),
|
|
onNavigate: (section) {
|
|
navigations.add(section);
|
|
setHostState(() {
|
|
showCreate = false;
|
|
});
|
|
},
|
|
onNewItem: () {
|
|
newItemTaps++;
|
|
setHostState(() {
|
|
showCreate = true;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('New Item'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(navigations, [OtoConsoleSection.pipelines]);
|
|
expect(newItemTaps, 1);
|
|
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));
|
|
|
|
await tester.tap(find.byTooltip('Pipelines'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Pipelines section'), findsOneWidget);
|
|
expect(find.text('Pipelines / Jobs'), findsOneWidget);
|
|
expect(find.text('No pipeline jobs'), findsOneWidget);
|
|
expect(find.byType(TextField), findsNothing);
|
|
});
|
|
|
|
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',
|
|
};
|
|
|
|
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<ElevatedButton>(
|
|
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<ElevatedButton>(
|
|
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<ElevatedButton>(
|
|
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<ElevatedButton>(
|
|
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<ElevatedButton>(createButton);
|
|
expect(disabledButton.onPressed, isNull);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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);
|
|
});
|
|
|
|
testWidgets(
|
|
'OtoJobsSurface hides job create form when create intent is off',
|
|
(tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: OtoJobsSurface(
|
|
snapshot: const OtoSurfaceSnapshot.empty(),
|
|
jobCreateState: const OtoActionViewState.idle(),
|
|
showCreateForm: false,
|
|
onCreateJob: (draft) {},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.byType(TextField), findsNothing);
|
|
expect(find.text('Create'), findsNothing);
|
|
expect(find.text('No pipeline jobs'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('OtoJobsSurface renders a create-only New Item surface', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: OtoJobsSurface(
|
|
snapshot: const OtoSurfaceSnapshot.empty(),
|
|
jobCreateState: const OtoActionViewState.idle(),
|
|
showCreateForm: true,
|
|
createOnly: true,
|
|
onCreateJob: (draft) {},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.text('Pipelines / Jobs'), findsNothing);
|
|
expect(find.text('Build Queue'), findsNothing);
|
|
expect(find.text('No pipeline jobs'), findsNothing);
|
|
expect(find.byType(TextField), findsNWidgets(2));
|
|
expect(find.text('Create'), findsOneWidget);
|
|
});
|
|
}
|