oto/apps/client/test/widget_test.dart
toki f76b7fa850 feat: control plane API data binding 구현 및 oto_console surface 추가
- execution surface 및 preview smoke 테스트 문서 업데이트
- client 앱 및 oto_console UI surface (artifacts, executions, jobs) 추가
- 관련 테스트 및 계약 문서 업데이트
- archive 구조 정리
2026-06-17 16:11:34 +09:00

586 lines
19 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.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);
});
_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();
}
}
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);
});
}