oto/apps/client/test/widget_test.dart

1768 lines
No EOL
57 KiB
Dart

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 = <String>[];
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);
});
_registerRunnerSelfUpdateTests();
_registerExecutionActionTests();
_registerJobCreateTests();
_registerJobsExecutionsTests();
}
http.Response _jsonResponse(Map<String, Object?> body) {
return http.Response(
jsonEncode(body),
200,
headers: {'content-type': 'application/json'},
);
}
class _FakeCoreClient implements OtoCoreConnectionClient {
@override
Future<OtoCoreConnectionSnapshot> 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<OtoRunnerRecord> Function(String runnerID)?
onFetchRunner;
final OtoCoreReadResult<OtoRunnerStatus> Function(String runnerID)?
onFetchRunnerStatus;
final OtoCoreReadResult<OtoJobRecord> Function(String jobID)?
onFetchJob;
final OtoCoreReadResult<OtoExecutionRecord> Function(String executionID)?
onFetchExecution;
final OtoCoreReadResult<List<OtoExecutionLogEntry>> Function(String executionID)?
onFetchLogs;
final OtoCoreReadResult<List<OtoArtifactRecord>> Function(String executionID)?
onFetchArtifacts;
OtoConsoleConfig? lastUsedConfig;
_FakeCoreReadClient({
this.onFetchRunner,
this.onFetchRunnerStatus,
this.onFetchJob,
this.onFetchExecution,
this.onFetchLogs,
this.onFetchArtifacts,
});
@override
Future<OtoCoreReadResult<OtoRunnerRecord>> fetchRunner(
OtoConsoleConfig config,
String runnerID,
) async {
lastUsedConfig = config;
if (onFetchRunner != null) {
return onFetchRunner!(runnerID);
}
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<OtoRunnerStatus>> fetchRunnerStatus(
OtoConsoleConfig config,
String runnerID,
) async {
lastUsedConfig = config;
if (onFetchRunnerStatus != null) {
return onFetchRunnerStatus!(runnerID);
}
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<OtoJobRecord>> fetchJob(
OtoConsoleConfig config,
String jobID,
) async {
lastUsedConfig = config;
if (onFetchJob != null) {
return onFetchJob!(jobID);
}
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<OtoExecutionRecord>> fetchExecution(
OtoConsoleConfig config,
String executionID,
) async {
lastUsedConfig = config;
if (onFetchExecution != null) {
return onFetchExecution!(executionID);
}
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> fetchLogs(
OtoConsoleConfig config,
String executionID,
) async {
lastUsedConfig = config;
if (onFetchLogs != null) {
return onFetchLogs!(executionID);
}
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<List<OtoArtifactRecord>>> fetchArtifacts(
OtoConsoleConfig config,
String executionID,
) async {
lastUsedConfig = config;
if (onFetchArtifacts != null) {
return onFetchArtifacts!(executionID);
}
return const OtoCoreReadResult.empty();
}
}
class _FakeCoreWriteClient implements OtoCoreWriteClient {
final Future<OtoCoreWriteResult<OtoJobRecord>> Function(
OtoConsoleConfig config,
OtoJobCreateDraft draft,
)? onCreateJob;
final Future<OtoCoreWriteResult<void>> Function(
OtoConsoleConfig config,
OtoExecutionCancelDraft draft,
)? onCancelExecution;
final Future<OtoCoreWriteResult<void>> Function(
OtoConsoleConfig config,
OtoExecutionReportDraft draft,
)? onReportExecution;
final Future<OtoCoreWriteResult<void>> Function(
OtoConsoleConfig config,
OtoExecutionLogDraft draft,
)? onAppendExecutionLog;
final Future<OtoCoreWriteResult<void>> Function(
OtoConsoleConfig config,
OtoExecutionArtifactDraft draft,
)? onAppendExecutionArtifact;
final Future<OtoCoreWriteResult<void>> Function(
OtoConsoleConfig config,
OtoRunnerSelfUpdateDraft draft,
)? onSelfUpdateRunner;
OtoConsoleConfig? lastUsedConfig;
OtoJobCreateDraft? lastUsedDraft;
_FakeCoreWriteClient({
this.onCreateJob,
this.onCancelExecution,
this.onReportExecution,
this.onAppendExecutionLog,
this.onAppendExecutionArtifact,
this.onSelfUpdateRunner,
});
@override
Future<OtoCoreWriteResult<OtoJobRecord>> createJob(
OtoConsoleConfig config,
OtoJobCreateDraft draft,
) async {
lastUsedConfig = config;
lastUsedDraft = draft;
if (onCreateJob != null) {
return onCreateJob!(config, draft);
}
return const OtoCoreWriteResult.failed(message: 'No handler');
}
@override
Future<OtoCoreWriteResult<void>> cancelExecution(
OtoConsoleConfig config,
OtoExecutionCancelDraft draft,
) async {
lastUsedConfig = config;
if (onCancelExecution != null) {
return onCancelExecution!(config, draft);
}
return const OtoCoreWriteResult.failed(message: 'No handler');
}
@override
Future<OtoCoreWriteResult<void>> reportExecution(
OtoConsoleConfig config,
OtoExecutionReportDraft draft,
) async {
lastUsedConfig = config;
if (onReportExecution != null) {
return onReportExecution!(config, draft);
}
return const OtoCoreWriteResult.failed(message: 'No handler');
}
@override
Future<OtoCoreWriteResult<void>> appendExecutionLog(
OtoConsoleConfig config,
OtoExecutionLogDraft draft,
) async {
lastUsedConfig = config;
if (onAppendExecutionLog != null) {
return onAppendExecutionLog!(config, draft);
}
return const OtoCoreWriteResult.failed(message: 'No handler');
}
@override
Future<OtoCoreWriteResult<void>> appendExecutionArtifact(
OtoConsoleConfig config,
OtoExecutionArtifactDraft draft,
) async {
lastUsedConfig = config;
if (onAppendExecutionArtifact != null) {
return onAppendExecutionArtifact!(config, draft);
}
return const OtoCoreWriteResult.failed(message: 'No handler');
}
@override
Future<OtoCoreWriteResult<void>> selfUpdateRunner(
OtoConsoleConfig config,
OtoRunnerSelfUpdateDraft draft,
) async {
lastUsedConfig = config;
if (onSelfUpdateRunner != null) {
return onSelfUpdateRunner!(config, draft);
}
return const OtoCoreWriteResult.failed(message: 'No handler');
}
}
void _registerRunnerSelfUpdateTests() {
test('OtoHttpCoreWriteClient maps runner self-update responses', () async {
final requestedPaths = <String>[];
final requestedBodies = <String>[];
final client = OtoHttpCoreWriteClient(
httpClient: MockClient((request) async {
requestedPaths.add(request.url.path);
requestedBodies.add(request.body);
return switch (request.url.path) {
'/api/v1/runners/runner-1/self-update' => http.Response(
jsonEncode({'accepted': true}),
200,
headers: {'content-type': 'application/json'},
),
'/api/v1/runners/runner-deferred/self-update' => http.Response(
jsonEncode({'deferred': true, 'message': 'Update scheduled for next restart'}),
200,
headers: {'content-type': 'application/json'},
),
'/api/v1/runners/runner-error/self-update' => http.Response(
jsonEncode({'success': false, 'error_message': 'Runner not found'}),
404,
headers: {'content-type': 'application/json'},
),
'/api/v1/runners/runner-bad/self-update' => http.Response(
jsonEncode({'error_message': 'Invalid version format'}),
200,
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',
);
// --- Accepted ---
final acceptedDraft = const OtoRunnerSelfUpdateDraft(
runnerID: 'runner-1',
version: 'v1.2.3',
downloadUrl: 'https://releases.example.test/oto-agent',
);
var result = await client.selfUpdateRunner(config, acceptedDraft);
expect(result.state, OtoCoreWriteState.succeeded);
expect(result.message, 'Update accepted');
expect(requestedPaths, ['/api/v1/runners/runner-1/self-update']);
final body = jsonDecode(requestedBodies.first) as Map<String, dynamic>;
expect(body['runner_id'], 'runner-1');
expect(body['version'], 'v1.2.3');
expect(body['download_url'], 'https://releases.example.test/oto-agent');
// --- Deferred ---
final deferredDraft = const OtoRunnerSelfUpdateDraft(
runnerID: 'runner-deferred',
version: 'v1.2.3',
downloadUrl: 'https://releases.example.test/oto-agent',
);
result = await client.selfUpdateRunner(config, deferredDraft);
expect(result.state, OtoCoreWriteState.deferred);
expect(result.message, 'Update scheduled for next restart');
// --- Error (404 with error_message) ---
final errorDraft = const OtoRunnerSelfUpdateDraft(
runnerID: 'runner-error',
version: 'v1.2.3',
downloadUrl: 'https://releases.example.test/oto-agent',
);
result = await client.selfUpdateRunner(config, errorDraft);
expect(result.state, OtoCoreWriteState.failed);
expect(result.message, 'Runner not found');
expect(result.statusCode, 404);
// --- Error (200 + error_message) ---
final badDraft = const OtoRunnerSelfUpdateDraft(
runnerID: 'runner-bad',
version: 'bad',
downloadUrl: 'https://releases.example.test/oto-agent',
);
result = await client.selfUpdateRunner(config, badDraft);
expect(result.state, OtoCoreWriteState.failed);
expect(result.message, 'Invalid version format');
});
testWidgets('OtoClientApp wires runner self-update and refresh', (tester) async {
final fetchLog = <String>[];
final fakeWriteClient = _FakeCoreWriteClient(
onSelfUpdateRunner: (config, draft) async {
fetchLog.add('selfUpdate:${draft.runnerID}:${draft.version}');
return const OtoCoreWriteResult<void>.succeeded(
null,
message: 'Update accepted',
);
},
);
final fakeReadClient = _FakeCoreReadClient(
onFetchRunner: (id) {
fetchLog.add('fetchRunner:$id');
return OtoCoreReadResult.data(
OtoRunnerRecord(
runnerID: id,
alias: 'Test Runner',
protocolVersion: 'v1',
status: 'idle',
acceptedAt: DateTime.now(),
firstHeartbeatAt: DateTime.now(),
lastHeartbeatAt: DateTime.now(),
failureReason: '',
),
);
},
onFetchRunnerStatus: (id) {
fetchLog.add('fetchRunnerStatus:$id');
return OtoCoreReadResult.data(
OtoRunnerStatus(
accepted: true,
runnerID: id,
status: 'idle',
currentJobID: '',
currentExecutionID: '',
message: '',
),
);
},
);
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
writeClient: fakeWriteClient,
runnerIDs: const ['runner-1'],
),
);
await tester.pumpAndSettle();
// Navigate to Runners tab
await tester.tap(find.byTooltip('Runners'));
await tester.pumpAndSettle();
expect(find.text('ID: runner-1'), findsOneWidget);
expect(find.text('Self Update'), findsOneWidget);
// Clear fetch log after initial load
fetchLog.clear();
// Enter version and HTTPS URL
await tester.enterText(find.byType(TextField).at(0), 'v2.0.0');
await tester.pump();
await tester.enterText(find.byType(TextField).at(1), 'https://releases.example.test/oto-agent');
await tester.pump();
// Tap Update button
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
// Verify write client was called with correct args
expect(fetchLog, contains('selfUpdate:runner-1:v2.0.0'));
// Verify accepted triggers _refreshCoreStatus (fetchRunnerStatus)
expect(
fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(),
isNotEmpty,
reason: 'accepted should trigger _refreshCoreStatus with fetchRunnerStatus',
);
// Verify success state is shown
expect(find.textContaining('Update accepted'), findsOneWidget);
});
testWidgets('OtoClientApp runner self-update deferred triggers refresh', (tester) async {
final fetchLog = <String>[];
final fakeWriteClient = _FakeCoreWriteClient(
onSelfUpdateRunner: (config, draft) async {
fetchLog.add('selfUpdate:${draft.runnerID}');
return const OtoCoreWriteResult<void>.deferred(
message: 'Scheduled for next restart',
);
},
);
final fakeReadClient = _FakeCoreReadClient(
onFetchRunner: (id) => OtoCoreReadResult.data(
OtoRunnerRecord(
runnerID: id,
alias: '',
protocolVersion: 'v1',
status: 'idle',
acceptedAt: null,
firstHeartbeatAt: null,
lastHeartbeatAt: null,
failureReason: '',
),
),
onFetchRunnerStatus: (id) {
fetchLog.add('fetchRunnerStatus:$id');
return OtoCoreReadResult.data(
OtoRunnerStatus(
accepted: true,
runnerID: id,
status: 'idle',
currentJobID: '',
currentExecutionID: '',
message: '',
),
);
},
);
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
writeClient: fakeWriteClient,
runnerIDs: const ['runner-1'],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Runners'));
await tester.pumpAndSettle();
fetchLog.clear();
await tester.enterText(find.byType(TextField).at(0), 'v2.0.0');
await tester.pump();
await tester.enterText(find.byType(TextField).at(1), 'https://releases.example.test/oto-agent');
await tester.pump();
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
// Deferred also triggers refresh
expect(
fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(),
isNotEmpty,
reason: 'deferred should trigger _refreshCoreStatus',
);
expect(find.textContaining('Deferred: Scheduled for next restart'), findsOneWidget);
});
testWidgets('OtoClientApp runner self-update failed does not trigger refresh', (tester) async {
final fetchLog = <String>[];
final fakeWriteClient = _FakeCoreWriteClient(
onSelfUpdateRunner: (config, draft) async {
fetchLog.add('selfUpdate');
return const OtoCoreWriteResult<void>.failed(message: 'Runner not found');
},
);
final fakeReadClient = _FakeCoreReadClient(
onFetchRunner: (id) => OtoCoreReadResult.data(
OtoRunnerRecord(
runnerID: id,
alias: '',
protocolVersion: 'v1',
status: 'idle',
acceptedAt: null,
firstHeartbeatAt: null,
lastHeartbeatAt: null,
failureReason: '',
),
),
onFetchRunnerStatus: (id) {
fetchLog.add('fetchRunnerStatus:$id');
return const OtoCoreReadResult.empty();
},
);
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
writeClient: fakeWriteClient,
runnerIDs: const ['runner-1'],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Runners'));
await tester.pumpAndSettle();
fetchLog.clear();
await tester.enterText(find.byType(TextField).at(0), 'v2.0.0');
await tester.pump();
await tester.enterText(find.byType(TextField).at(1), 'https://releases.example.test/oto-agent');
await tester.pump();
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
// Failed does NOT trigger refresh
expect(
fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(),
isEmpty,
reason: 'failed self-update should not trigger _refreshCoreStatus',
);
expect(find.textContaining('Runner not found'), findsOneWidget);
});
}
void _registerExecutionActionTests() {
testWidgets('OtoHttpCoreWriteClient maps execution action responses', (tester) async {
final requestedPaths = <String>[];
final client = OtoHttpCoreWriteClient(
httpClient: MockClient((request) async {
requestedPaths.add(request.url.path);
return switch (request.url.path) {
'/api/v1/runners/runner-1/executions/exec-1/cancel' => http.Response(
jsonEncode({'success': true}),
200,
headers: {'content-type': 'application/json'},
),
'/api/v1/runners/runner-1/executions/exec-1/report' => http.Response(
jsonEncode({'accepted': true, 'job_id': 'job-1', 'execution_id': 'exec-1', 'state': 'succeeded', 'runner_id': 'runner-1'}),
200,
headers: {'content-type': 'application/json'},
),
'/api/v1/runners/runner-1/executions/exec-1/logs' => http.Response(
jsonEncode({'accepted': true}),
201,
headers: {'content-type': 'application/json'},
),
'/api/v1/runners/runner-1/executions/exec-1/artifacts' => http.Response(
jsonEncode({'accepted': true}),
201,
headers: {'content-type': 'application/json'},
),
// Error cases
'/api/v1/executions/exec-error/cancel' => http.Response(
jsonEncode({'success': false, 'error_message': 'Execution not found'}),
404,
headers: {'content-type': 'application/json'},
),
'/api/v1/executions/exec-bad/report' => http.Response(
jsonEncode({'accepted': false}),
400,
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',
);
// --- Cancel success ---
final cancelDraft = const OtoExecutionCancelDraft(
executionID: 'exec-1',
runnerID: 'runner-1',
reason: 'Test cancel',
);
var result = await client.cancelExecution(config, cancelDraft);
expect(result.state, OtoCoreWriteState.succeeded);
expect(result.message, 'cancel accepted');
expect(requestedPaths, ['/api/v1/runners/runner-1/executions/exec-1/cancel']);
// --- Report success ---
final reportDraft = const OtoExecutionReportDraft(
executionID: 'exec-1',
runnerID: 'runner-1',
jobID: 'job-1',
success: true,
exitCode: 0,
message: 'Done',
);
result = await client.reportExecution(config, reportDraft);
expect(result.state, OtoCoreWriteState.succeeded);
expect(result.message, 'report accepted');
// --- Log success ---
final logDraft = const OtoExecutionLogDraft(
executionID: 'exec-1',
runnerID: 'runner-1',
line: 'test log line',
);
result = await client.appendExecutionLog(config, logDraft);
expect(result.state, OtoCoreWriteState.succeeded);
expect(result.message, 'log accepted');
// --- Artifact success ---
final artifactDraft = const OtoExecutionArtifactDraft(
executionID: 'exec-1',
runnerID: 'runner-1',
name: 'test.txt',
path: '/tmp/test.txt',
);
result = await client.appendExecutionArtifact(config, artifactDraft);
expect(result.state, OtoCoreWriteState.succeeded);
expect(result.message, 'artifact accepted');
// --- Cancel 404 error ---
final cancelErrorDraft = const OtoExecutionCancelDraft(executionID: 'exec-error');
result = await client.cancelExecution(config, cancelErrorDraft);
expect(result.state, OtoCoreWriteState.failed);
expect(result.message, 'Execution not found');
expect(result.statusCode, 404);
// --- Report 400 error (accepted=false) ---
final reportErrorDraft = const OtoExecutionReportDraft(
executionID: 'exec-bad',
success: true,
);
result = await client.reportExecution(config, reportErrorDraft);
expect(result.state, OtoCoreWriteState.failed);
});
testWidgets('OtoClientApp wires execution actions and refresh', (tester) async {
// Test that execution actions show on expanded execution
final writeCalls = <String>[];
final fakeWriteClient = _FakeCoreWriteClient(
onCancelExecution: (config, draft) async {
writeCalls.add('cancel:${draft.executionID}');
return const OtoCoreWriteResult.failed(message: 'cancel rejected');
},
onReportExecution: (config, draft) async {
writeCalls.add('report:${draft.executionID}');
return const OtoCoreWriteResult.failed(message: 'report rejected');
},
);
final fakeReadClient = _FakeCoreReadClient(
onFetchExecution: (id) => OtoCoreReadResult.data(
OtoExecutionRecord(
id: id,
jobID: 'job-test',
state: 'running',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
executionID: id,
),
),
onFetchRunnerStatus: (id) => OtoCoreReadResult.data(
OtoRunnerStatus(
accepted: true,
runnerID: id,
status: 'online',
currentJobID: 'job-1',
currentExecutionID: 'exec-action-test',
message: 'Running',
),
),
);
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
writeClient: fakeWriteClient,
runnerIDs: const ['runner-1'],
executionIDs: const ['exec-action-test'],
),
);
await tester.pumpAndSettle();
// Go to Executions tab and expand
await tester.tap(find.byTooltip('Executions'));
await tester.pumpAndSettle();
expect(find.text('Exec ID: exec-action-test'), findsOneWidget);
await tester.tap(find.text('Exec ID: exec-action-test'));
await tester.pumpAndSettle();
// Actions section should be visible
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);
// Tap Report button — should call write client and show failed state
await tester.tap(find.text('Report'));
await tester.pumpAndSettle();
expect(writeCalls, contains('report:exec-action-test'));
expect(find.textContaining('report rejected'), findsOneWidget);
// Failed action does not cause read reloads — only action state shows error
// (No refresh is triggered on failure)
});
testWidgets('OtoClientApp execution action success triggers refresh with real runner id', (tester) async {
final fetchLog = <String>[];
final fakeWriteClient = _FakeCoreWriteClient(
onReportExecution: (config, draft) async {
fetchLog.add('report:${draft.executionID}:runner:${draft.runnerID}');
// Return success state — use state check, not isSuccess (data is null)
return OtoCoreWriteResult<void>.succeeded(
null,
message: 'report accepted',
);
},
);
final fakeReadClient = _FakeCoreReadClient(
onFetchExecution: (id) {
fetchLog.add('fetchExecution:$id');
return OtoCoreReadResult.data(
OtoExecutionRecord(
id: id,
jobID: 'job-test',
state: 'running',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
executionID: id,
),
);
},
onFetchRunnerStatus: (id) {
fetchLog.add('fetchRunnerStatus:$id');
return OtoCoreReadResult.data(
OtoRunnerStatus(
accepted: true,
runnerID: id,
status: 'online',
currentJobID: 'job-1',
currentExecutionID: 'exec-action-test',
message: 'Running',
),
);
},
onFetchLogs: (id) {
fetchLog.add('fetchLogs:$id');
return const OtoCoreReadResult.data(<OtoExecutionLogEntry>[]);
},
onFetchArtifacts: (id) {
fetchLog.add('fetchArtifacts:$id');
return const OtoCoreReadResult.data(<OtoArtifactRecord>[]);
},
);
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
writeClient: fakeWriteClient,
runnerIDs: const ['runner-1'],
executionIDs: const ['exec-action-test'],
),
);
await tester.pumpAndSettle();
// Go to Executions tab and expand
await tester.tap(find.byTooltip('Executions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Exec ID: exec-action-test'));
await tester.pumpAndSettle();
// Clear fetch log after initial load
fetchLog.clear();
// Tap Report button — should call write client and show success state
await tester.tap(find.text('Report'));
await tester.pumpAndSettle();
// Verify runner id is real, not execution id
expect(fetchLog, contains('report:exec-action-test:runner:runner-1'));
// Verify success triggers _refreshCoreStatus() which includes fetchRunnerStatus
expect(
fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(),
isNotEmpty,
reason: 'success should trigger _refreshCoreStatus() with fetchRunnerStatus',
);
// Verify success triggers _loadExecutionDetail(executionID) which includes fetchExecution, fetchLogs, fetchArtifacts
expect(
fetchLog.where((e) => e.startsWith('fetchExecution:')).toList(),
isNotEmpty,
reason: 'success should trigger _loadExecutionDetail() with fetchExecution',
);
expect(
fetchLog.where((e) => e.startsWith('fetchLogs:')).toList(),
isNotEmpty,
reason: 'success should trigger _loadExecutionDetail() with fetchLogs',
);
expect(
fetchLog.where((e) => e.startsWith('fetchArtifacts:')).toList(),
isNotEmpty,
reason: 'success should trigger _loadExecutionDetail() with fetchArtifacts',
);
});
testWidgets('OtoClientApp unknown runner id keeps action disabled', (tester) async {
final writeCalls = <String>[];
final fakeWriteClient = _FakeCoreWriteClient(
onReportExecution: (config, draft) async {
writeCalls.add('report:${draft.executionID}:runner:${draft.runnerID}');
return const OtoCoreWriteResult<void>.succeeded(null, message: 'report accepted');
},
);
// Runner status has no currentExecutionID matching our execution
final fakeReadClient = _FakeCoreReadClient(
onFetchExecution: (id) => OtoCoreReadResult.data(
OtoExecutionRecord(
id: id,
jobID: 'job-test',
state: 'running',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
executionID: id,
),
),
onFetchRunnerStatus: (id) => OtoCoreReadResult.data(
OtoRunnerStatus(
accepted: true,
runnerID: id,
status: 'online',
currentJobID: 'job-other',
currentExecutionID: 'exec-different',
message: 'Running',
),
),
);
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
writeClient: fakeWriteClient,
runnerIDs: const ['runner-1'],
executionIDs: const ['exec-no-runner'],
),
);
await tester.pumpAndSettle();
// Go to Executions tab and expand
await tester.tap(find.byTooltip('Executions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Exec ID: exec-no-runner'));
await tester.pumpAndSettle();
// Actions section should be visible
expect(find.text('Actions'), findsOneWidget);
// Tap Report button — even though we tap, the widget should not call write client
// because runnerID is empty string (disabled)
await tester.tap(find.text('Report'));
await tester.pumpAndSettle();
// When runner id is unknown (empty), the action callback should NOT be called
// because the UI shows disabled buttons
expect(writeCalls, isEmpty, reason: 'unknown runner should keep action disabled');
});
}
void _registerJobCreateTests() {
testWidgets('OtoHttpCoreWriteClient maps job create responses', (tester) async {
final requestedPaths = <String>[];
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<void>.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<OtoJobRecord> 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<ElevatedButton>(
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<ElevatedButton>(
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<OtoCoreWriteResult<OtoJobRecord>>();
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 = <String>[];
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);
});
}