- execution surface 및 preview smoke 테스트 문서 업데이트 - client 앱 및 oto_console UI surface (artifacts, executions, jobs) 추가 - 관련 테스트 및 계약 문서 업데이트 - archive 구조 정리
586 lines
19 KiB
Dart
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);
|
|
});
|
|
}
|