- OtoConsoleShell에 onNewItem 콜백 파라미터와 _MenuAction 활성 액션 상태를 도입한다 - OtoJobsSurface에 showCreateForm 파라미터를 추가하여 폼 표시/숨김을 외부에서 제어할 수 있게 한다 - OtoClientApp에 _showJobCreateForm 상태 변수를 추가하여 폼 토글을 구현한다 - 관련 테스트를 업데이트한다
1908 lines
60 KiB
Dart
1908 lines
60 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'},
|
|
);
|
|
}
|
|
|
|
Finder _newItemMenuButton() {
|
|
return find.ancestor(
|
|
of: find.text('New Item'),
|
|
matching: find.byType(InkWell),
|
|
);
|
|
}
|
|
|
|
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'},
|
|
),
|
|
_ => 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 without runner id is rejected before hitting a nonexistent Core route ---
|
|
final cancelErrorDraft = const OtoExecutionCancelDraft(
|
|
executionID: 'exec-error',
|
|
);
|
|
result = await client.cancelExecution(config, cancelErrorDraft);
|
|
expect(result.state, OtoCoreWriteState.failed);
|
|
expect(result.message, 'Runner ID is required');
|
|
expect(
|
|
requestedPaths,
|
|
isNot(contains('/api/v1/executions/exec-error/cancel')),
|
|
);
|
|
|
|
// --- Report without runner id is rejected before hitting a nonexistent Core route ---
|
|
final reportErrorDraft = const OtoExecutionReportDraft(
|
|
executionID: 'exec-bad',
|
|
success: true,
|
|
);
|
|
result = await client.reportExecution(config, reportErrorDraft);
|
|
expect(result.state, OtoCoreWriteState.failed);
|
|
expect(result.message, 'Runner ID is required');
|
|
expect(
|
|
requestedPaths,
|
|
isNot(contains('/api/v1/executions/exec-bad/report')),
|
|
);
|
|
});
|
|
|
|
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');
|
|
},
|
|
onAppendExecutionLog: (config, draft) async {
|
|
writeCalls.add('log:${draft.executionID}');
|
|
return const OtoCoreWriteResult.failed(message: 'log rejected');
|
|
},
|
|
onAppendExecutionArtifact: (config, draft) async {
|
|
writeCalls.add('artifact:${draft.executionID}');
|
|
return const OtoCoreWriteResult.failed(message: 'artifact 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);
|
|
|
|
// Tap Log and Artifact to verify both write callbacks are wired.
|
|
await tester.tap(find.text('Log'));
|
|
await tester.pumpAndSettle();
|
|
expect(writeCalls, contains('log:exec-action-test'));
|
|
expect(find.textContaining('log rejected'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Artifact'));
|
|
await tester.pumpAndSettle();
|
|
expect(writeCalls, contains('artifact:exec-action-test'));
|
|
expect(find.textContaining('artifact 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();
|
|
|
|
// Pipelines stays a list surface; New Item owns the create form.
|
|
await tester.tap(find.byTooltip('Pipelines'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('No pipeline jobs'), findsOneWidget);
|
|
expect(find.byType(TextField), findsNothing);
|
|
|
|
await tester.tap(_newItemMenuButton());
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('New Item section'), 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 New Item 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(_newItemMenuButton());
|
|
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(_newItemMenuButton());
|
|
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(_newItemMenuButton());
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('New Item section'), 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']);
|
|
|
|
// New Item should have create form visible.
|
|
await tester.tap(_newItemMenuButton());
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('New Item section'), findsOneWidget);
|
|
expect(find.byType(TextField), findsNWidgets(2));
|
|
});
|
|
}
|
|
|
|
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('Run exec-99'), 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);
|
|
// 'Console Output' appears once in the run-context task menu and once
|
|
// as the logs pane title.
|
|
expect(find.text('Console Output'), findsNWidgets(2));
|
|
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,
|
|
);
|
|
},
|
|
);
|
|
}
|