oto/apps/client/test/widget_test.dart
toki 002f9cd521 feat: Control Plane API 데이터 바인딩 및 oto_console 런너urface 추가
- Control Plane API 데이터 바인딩 마일스톤 문서 업데이트
- core_connection_client 및 oto_client_app 수정
- oto_console 런너.surface 모듈 추가
- 관련 테스트 파일 업데이트
2026-06-17 13:50:29 +09:00

345 lines
11 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);
});
}
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;
OtoConsoleConfig? lastUsedConfig;
_FakeCoreReadClient({this.onFetchRunner, this.onFetchRunnerStatus});
@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 {
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<OtoExecutionRecord>> fetchExecution(
OtoConsoleConfig config,
String executionID,
) async {
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> fetchLogs(
OtoConsoleConfig config,
String executionID,
) async {
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<List<OtoArtifactRecord>>> fetchArtifacts(
OtoConsoleConfig config,
String executionID,
) async {
return const OtoCoreReadResult.empty();
}
}