core data composition과 Application 출력 경계에서 CLI Color 의존을 분리하고, 기존 CLI 출력 의미를 adapter 테스트로 보존하기 위해 변경한다.
1138 lines
29 KiB
Dart
1138 lines
29 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:oto/cli/cli_style.dart';
|
|
import 'package:oto/cli/commands/command_exe.dart';
|
|
import 'package:oto/cli/runner_output_adapter.dart';
|
|
import 'package:oto/oto/application.dart';
|
|
import 'package:oto/oto/core/build_result.dart';
|
|
import 'package:oto/oto/core/execution_context.dart';
|
|
import 'package:oto/oto/core/output_port.dart';
|
|
import 'package:oto/oto/data/command_data.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
setUp(() {
|
|
Application.instance.property = {};
|
|
Application.instance.commandStates = {};
|
|
Application.instance.dataCommandMap = {};
|
|
Application.instance.context.output = const NoopRunnerOutputPort();
|
|
});
|
|
|
|
test('build returns failure instead of exiting on invalid yaml', () async {
|
|
const invalidYaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: doesNotExist
|
|
''';
|
|
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: invalidYaml,
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.exitCode, 10);
|
|
expect(result.error, isNotNull);
|
|
});
|
|
|
|
test('file build returns success for print-only pipeline', () async {
|
|
const yaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
|
|
expect(result.success, isTrue);
|
|
expect(result.exitCode, 0);
|
|
});
|
|
|
|
test('file build emits build boundaries through output port', () async {
|
|
const yaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final output = _RecordingOutputPort();
|
|
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
output: output,
|
|
);
|
|
|
|
expect(result.success, isTrue);
|
|
expect(output.blocks, anyElement(contains('Build Data')));
|
|
expect(output.steps, anyElement(contains('Phase Start')));
|
|
expect(output.steps, contains('Build Successfully Complete'));
|
|
|
|
// REVIEW_REFACTOR-1: 최종 성공 배너는 기존 cyan 의미(RunnerOutputStyle.progress) 보존
|
|
final completeIdx = output.steps.indexOf('Build Successfully Complete');
|
|
expect(completeIdx, isNot(equals(-1)));
|
|
expect(output.stepStyles[completeIdx], equals(RunnerOutputStyle.progress));
|
|
});
|
|
|
|
// REVIEW_REFACTOR-1: 기존 build data, phase start, failure 출력 의미 회귀 없음
|
|
test(
|
|
'REVIEW_REFACTOR-1: build data block uses RunnerOutputStyle.accent (magenta) - no regression',
|
|
() async {
|
|
const yaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final output = _RecordingOutputPort();
|
|
|
|
await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
output: output,
|
|
);
|
|
|
|
// Build Data block은 RunnerOutputStyle.accent (magenta, 기존과 동일)
|
|
final hasBuildData = output.blocks.any((b) => b.contains('Build Data'));
|
|
expect(hasBuildData, isTrue);
|
|
|
|
// Phase Start는 RunnerOutputStyle.success ( green, 기존과 동일)
|
|
final phaseStartIdx = output.steps.indexOf('Phase Start: Print (hello)');
|
|
expect(phaseStartIdx, isNot(equals(-1)));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'REVIEW_REFACTOR-1: failure banner uses RunnerOutputStyle.error (red) - no regression',
|
|
() async {
|
|
const invalidYaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: doesNotExist
|
|
''';
|
|
final output = _RecordingOutputPort();
|
|
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: invalidYaml,
|
|
output: output,
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
// Build Failed는 RunnerOutputStyle.error로 호출됨
|
|
final failIdx = output.steps.indexOf('Build Failed');
|
|
expect(failIdx, isNot(equals(-1)));
|
|
// error 스타일이 기록되었는지 확인
|
|
expect(output.stepStyles[failIdx], equals(RunnerOutputStyle.error));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'file build without property workspace uses local workspace without Jenkins env',
|
|
() async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
logEnable: false,
|
|
);
|
|
|
|
expect(result.success, isTrue);
|
|
expect(Application.instance.property['workspace'], isNotNull);
|
|
expect(
|
|
Application.instance.property['workspace'],
|
|
Directory.current.path.replaceAll('\\', '/'),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('file build can run twice in same process', () async {
|
|
const yaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
|
|
final first = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
final second = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
|
|
expect(first.success, isTrue);
|
|
expect(second.success, isTrue);
|
|
});
|
|
|
|
test('build fails with message when YAML root is not a map', () async {
|
|
const yaml = '''
|
|
- command: Print
|
|
id: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), contains('Build YAML root must be a map'));
|
|
});
|
|
|
|
test('build fails with message when property is not a map', () async {
|
|
const yaml = '''
|
|
property:
|
|
- bad
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), contains('Validate build yaml'));
|
|
});
|
|
|
|
test('build fails with message when command id is not a string', () async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: 123
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), contains('Validate command list'));
|
|
});
|
|
|
|
test('build fails with message when command type is not a string', () async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: 999
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), contains('Validate command list'));
|
|
});
|
|
|
|
test(
|
|
'build fails with validation message when workflow key is not a string',
|
|
() async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- 123: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(
|
|
result.error.toString(),
|
|
isNot(contains('Converting object to an encodable object failed')),
|
|
);
|
|
expect(result.error.toString(), contains('workflow[0]'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'build fails with validation message when exe-handle branch is missing',
|
|
() async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe-handle:
|
|
id: hello
|
|
on-fail:
|
|
- exe: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(
|
|
result.error.toString(),
|
|
contains('exe-handle syntax requires on-success'),
|
|
);
|
|
},
|
|
);
|
|
|
|
// REVIEW_VALIDATE-1: exe command id type guard
|
|
test(
|
|
'build fails with validation message when exe command id is not a string',
|
|
() async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: 123
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), isNot(contains('is not a subtype')));
|
|
expect(result.error.toString(), contains('exe'));
|
|
},
|
|
);
|
|
|
|
// REVIEW_VALIDATE-1: wait-until-string condition type guard
|
|
test(
|
|
'build fails with validation message when wait-until-string value is not a string',
|
|
() async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- wait-until-string: 123
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), isNot(contains('is not a subtype')));
|
|
expect(result.error.toString(), contains('wait-until'));
|
|
},
|
|
);
|
|
|
|
// REVIEW_VALIDATE-3: nested pipeline malformed cases
|
|
test(
|
|
'build fails with validation message when exe-handle id is not a string',
|
|
() async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe-handle:
|
|
id: 123
|
|
on-success:
|
|
- exe: hello
|
|
on-fail:
|
|
- exe: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), isNot(contains('is not a subtype')));
|
|
expect(result.error.toString(), contains('exe-handle'));
|
|
expect(result.error.toString(), contains('id'));
|
|
},
|
|
);
|
|
|
|
// REVIEW_REVIEW_VALIDATE-1: pipeline.id validation
|
|
test(
|
|
'build fails with validation message when pipeline.id is missing',
|
|
() async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), isNot(contains('is not a subtype')));
|
|
expect(result.error.toString(), contains('pipeline.id'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'build fails with validation message when pipeline.id is not a string',
|
|
() async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: 123
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), isNot(contains('is not a subtype')));
|
|
expect(result.error.toString(), contains('pipeline.id'));
|
|
},
|
|
);
|
|
|
|
test('build fails with validation message when workflow is empty', () async {
|
|
const yaml = '''
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow: []
|
|
''';
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
);
|
|
expect(result.success, isFalse);
|
|
expect(result.error.toString(), isNot(contains('Null check operator')));
|
|
expect(result.error.toString(), contains('pipeline.workflow'));
|
|
});
|
|
|
|
test(
|
|
'CommandExe exe -f handles missing file without LateInitializationError',
|
|
() async {
|
|
final missing =
|
|
'/tmp/oto_missing_${DateTime.now().microsecondsSinceEpoch}.yaml';
|
|
expect(File(missing).existsSync(), isFalse);
|
|
|
|
final command = CommandExe();
|
|
final priorExitCode = exitCode;
|
|
try {
|
|
await command.execute(['-f', missing]);
|
|
} finally {
|
|
exitCode = priorExitCode;
|
|
}
|
|
},
|
|
);
|
|
|
|
group('Application.validateYamlContent', () {
|
|
const validYaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
|
|
test('validateYamlContent returns success contract for valid yaml', () {
|
|
final result = Application.validateYamlContent(validYaml);
|
|
expect(result.valid, isTrue);
|
|
expect(result.phase, 'Validate YAML');
|
|
expect(result.message, 'YAML is valid.');
|
|
expect(result.exitCode, 0);
|
|
});
|
|
|
|
test('validateYamlContent returns build yaml failure contract', () {
|
|
const invalidBuildYaml = '''
|
|
- command: Print
|
|
id: hello
|
|
''';
|
|
final result = Application.validateYamlContent(invalidBuildYaml);
|
|
expect(result.valid, isFalse);
|
|
expect(result.phase, 'Validate build yaml');
|
|
expect(result.message, contains('Build YAML root must be a map'));
|
|
expect(result.exitCode, 10);
|
|
});
|
|
|
|
test(
|
|
'validateYamlContent returns command list failure contract for duplicate id',
|
|
() {
|
|
const duplicateIdYaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hello again
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final result = Application.validateYamlContent(duplicateIdYaml);
|
|
expect(result.valid, isFalse);
|
|
expect(result.phase, 'Validate command list');
|
|
expect(
|
|
result.message,
|
|
contains('Duplicate command id exists: "hello"'),
|
|
);
|
|
expect(result.exitCode, 10);
|
|
},
|
|
);
|
|
|
|
test('validateYamlContent returns pipeline failure contract', () {
|
|
const invalidPipelineYaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: doesNotExist
|
|
''';
|
|
final result = Application.validateYamlContent(invalidPipelineYaml);
|
|
expect(result.valid, isFalse);
|
|
expect(result.phase, 'Validate Pipeline');
|
|
expect(result.message, contains('workflow[0]'));
|
|
expect(result.exitCode, 10);
|
|
});
|
|
|
|
test('validateYamlContent exposes stable json shape', () {
|
|
final result = Application.validateYamlContent(validYaml);
|
|
final json = result.toJson();
|
|
|
|
expect(json['schemaVersion'], 1);
|
|
expect(json['type'], 'yamlValidation');
|
|
expect(json['valid'], isTrue);
|
|
expect(json['phase'], 'Validate YAML');
|
|
expect(json['message'], 'YAML is valid.');
|
|
expect(json['exitCode'], 0);
|
|
});
|
|
|
|
test(
|
|
'validateYamlContent returns failure contract for malformed scheduler section',
|
|
() {
|
|
const malformedSchedulerYaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
scheduler: "not_a_map"
|
|
''';
|
|
final result = Application.validateYamlContent(malformedSchedulerYaml);
|
|
expect(result.valid, isFalse);
|
|
expect(result.phase, 'Validate build yaml');
|
|
expect(result.exitCode, 10);
|
|
expect(result.message, isNotEmpty);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('BuildResult.toJson', () {
|
|
test('exposes success execution envelope', () {
|
|
const result = BuildResult.success();
|
|
final json = result.toJson();
|
|
|
|
expect(json['schemaVersion'], 1);
|
|
expect(json['type'], 'executionResult');
|
|
expect(json['success'], isTrue);
|
|
expect(json['exitCode'], 0);
|
|
expect(json['message'], 'Build completed successfully.');
|
|
expect(json['error'], isNull);
|
|
expect(json['stepEvents'], isEmpty);
|
|
expect(json['artifacts'], isEmpty);
|
|
});
|
|
|
|
test('exposes failure execution envelope', () {
|
|
final error = Exception('Some critical error');
|
|
final stack = StackTrace.current;
|
|
final result = BuildResult.failure(error, stack, exitCode: 15);
|
|
final json = result.toJson();
|
|
|
|
expect(json['schemaVersion'], 1);
|
|
expect(json['type'], 'executionResult');
|
|
expect(json['success'], isFalse);
|
|
expect(json['exitCode'], 15);
|
|
expect(json['message'], contains('Some critical error'));
|
|
expect(json['error'], isNotNull);
|
|
expect(json['error']['type'], '_Exception');
|
|
expect(json['error']['message'], contains('Some critical error'));
|
|
expect(json['stepEvents'], isEmpty);
|
|
});
|
|
|
|
test('exposes populated step events', () {
|
|
final events = [
|
|
StepEvent(
|
|
stepId: 0,
|
|
workflowIndex: 0,
|
|
stepType: 'exe',
|
|
event: 'started',
|
|
timestamp: '2026-05-22T05:00:00Z',
|
|
commandId: 'hello',
|
|
commandType: 'Print',
|
|
),
|
|
];
|
|
final result = BuildResult.success(stepEvents: events);
|
|
final json = result.toJson();
|
|
|
|
expect(json['stepEvents'], hasLength(1));
|
|
expect(json['stepEvents'][0]['stepId'], 0);
|
|
expect(json['stepEvents'][0]['event'], 'started');
|
|
expect(json['stepEvents'][0]['commandId'], 'hello');
|
|
expect(json['stepEvents'][0]['commandType'], 'Print');
|
|
});
|
|
|
|
test('exposes declared artifacts', () {
|
|
const result = BuildResult.success(
|
|
artifacts: [
|
|
BuildArtifact(name: 'smoke-report', path: 'dist/report.json'),
|
|
],
|
|
);
|
|
final json = result.toJson();
|
|
|
|
expect(json['artifacts'], hasLength(1));
|
|
expect(json['artifacts'][0]['name'], 'smoke-report');
|
|
expect(json['artifacts'][0]['path'], 'dist/report.json');
|
|
});
|
|
|
|
test('failure envelope preserves declared artifacts', () {
|
|
final result = BuildResult.failure(
|
|
Exception('boom'),
|
|
StackTrace.current,
|
|
artifacts: const [BuildArtifact(name: 'log', path: 'dist/build.log')],
|
|
);
|
|
final json = result.toJson();
|
|
|
|
expect(json['success'], isFalse);
|
|
expect(json['artifacts'], hasLength(1));
|
|
expect(json['artifacts'][0]['name'], 'log');
|
|
expect(json['artifacts'][0]['path'], 'dist/build.log');
|
|
});
|
|
});
|
|
|
|
group('BuildResult regression - stepEvents snapshot', () {
|
|
test(
|
|
'first build result retains stepEvents after second build execution',
|
|
() async {
|
|
const validYaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
|
|
// 1. Run first build
|
|
final result1 = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: validYaml,
|
|
logEnable: false,
|
|
);
|
|
|
|
expect(result1.success, isTrue);
|
|
final events1 = result1.toJson()['stepEvents'] as List;
|
|
expect(events1, isNotEmpty);
|
|
final originalEventsCount = events1.length;
|
|
|
|
// 2. Run second build (which triggers context.resetStepEvents())
|
|
final result2 = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: validYaml,
|
|
logEnable: false,
|
|
);
|
|
|
|
expect(result2.success, isTrue);
|
|
|
|
// 3. Assert first build result still retains its original stepEvents
|
|
final events1AfterSecondBuild = result1.toJson()['stepEvents'] as List;
|
|
expect(events1AfterSecondBuild, hasLength(originalEventsCount));
|
|
expect(events1AfterSecondBuild[0]['event'], 'started');
|
|
expect(events1AfterSecondBuild[0]['commandId'], 'hello');
|
|
},
|
|
);
|
|
});
|
|
|
|
group('logEnable false', () {
|
|
test('build with logEnable false suppresses failure stdout', () async {
|
|
const invalidYaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: doesNotExist
|
|
''';
|
|
|
|
final prints = <String>[];
|
|
final result = await runZoned(
|
|
() async {
|
|
return await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: invalidYaml,
|
|
logEnable: false,
|
|
);
|
|
},
|
|
zoneSpecification: ZoneSpecification(
|
|
print: (self, parent, zone, line) {
|
|
prints.add(line);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(prints, isEmpty);
|
|
});
|
|
|
|
test('build with logEnable false suppresses command stdout logs', () async {
|
|
const yaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
|
|
final prints = <String>[];
|
|
final result = await runZoned(
|
|
() async {
|
|
return await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
logEnable: false,
|
|
);
|
|
},
|
|
zoneSpecification: ZoneSpecification(
|
|
print: (self, parent, zone, line) {
|
|
prints.add(line);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(result.success, isTrue);
|
|
expect(prints, isEmpty);
|
|
});
|
|
|
|
test('build with logEnable false suppresses output port logs', () async {
|
|
const yaml = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final output = _RecordingOutputPort();
|
|
|
|
final result = await Application.instance.build(
|
|
BuildType.file,
|
|
yamlContent: yaml,
|
|
logEnable: false,
|
|
output: output,
|
|
);
|
|
|
|
expect(result.success, isTrue);
|
|
expect(output.lines, isEmpty);
|
|
expect(output.blocks, isEmpty);
|
|
expect(output.steps, isEmpty);
|
|
});
|
|
});
|
|
|
|
test('scheduler build data runs without Jenkins env', () async {
|
|
final logFile = File(
|
|
'${Directory.systemTemp.path}/oto_scheduler_${DateTime.now().microsecondsSinceEpoch}.log',
|
|
);
|
|
addTearDown(() {
|
|
if (logFile.existsSync()) logFile.deleteSync();
|
|
});
|
|
const yaml = '''
|
|
scheduler:
|
|
alias: local-test
|
|
interval: 1000
|
|
enableLog: true
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
final build = DataBuild.fromJson(Application.getMapFromYamlA(yaml)!);
|
|
|
|
final result = await Application.instance.build(
|
|
BuildType.scheduler,
|
|
buildData: build,
|
|
logEnable: false,
|
|
logPath: logFile.path,
|
|
);
|
|
|
|
expect(result.success, isTrue);
|
|
});
|
|
|
|
group('CommandExe JSON Mode', () {
|
|
test(
|
|
'CommandExe shouldPrintExecuteLog suppresses execute log for json',
|
|
() {
|
|
final command = CommandExe();
|
|
expect(command.shouldPrintExecuteLog(['--json']), isFalse);
|
|
expect(command.shouldPrintExecuteLog(['-f', 'file.yaml']), isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'CommandExe exe -f missing file --json returns execution result json',
|
|
() async {
|
|
final missing =
|
|
'/tmp/oto_missing_${DateTime.now().microsecondsSinceEpoch}.yaml';
|
|
expect(File(missing).existsSync(), isFalse);
|
|
|
|
final prints = <String>[];
|
|
final command = CommandExe(
|
|
printString: (value) async {
|
|
prints.add(value);
|
|
},
|
|
);
|
|
|
|
final priorExitCode = exitCode;
|
|
try {
|
|
await command.execute(['-f', missing, '--json']);
|
|
} finally {
|
|
exitCode = priorExitCode;
|
|
}
|
|
|
|
expect(prints, hasLength(1));
|
|
final json = jsonDecode(prints[0]);
|
|
expect(json['schemaVersion'], 1);
|
|
expect(json['type'], 'executionResult');
|
|
expect(json['success'], isFalse);
|
|
expect(json['exitCode'], 10);
|
|
expect(json['message'], contains('There are no files in path'));
|
|
expect(json['stepEvents'], isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'actual bin exe --json output has parseable execution result json and no execute logs',
|
|
() async {
|
|
final tempFile = File(
|
|
'${Directory.systemTemp.path}/oto_temp_${DateTime.now().microsecondsSinceEpoch}.yaml',
|
|
);
|
|
const yamlContent = '''
|
|
property:
|
|
workspace: .
|
|
commands:
|
|
- command: Print
|
|
id: hello
|
|
param:
|
|
message: hi
|
|
pipeline:
|
|
id: main
|
|
workflow:
|
|
- exe: hello
|
|
''';
|
|
tempFile.writeAsStringSync(yamlContent);
|
|
|
|
try {
|
|
final result = await Process.run('dart', [
|
|
'run',
|
|
'bin/main.dart',
|
|
'exe',
|
|
'-f',
|
|
tempFile.path,
|
|
'--json',
|
|
]);
|
|
|
|
expect(result.exitCode, 0);
|
|
|
|
final stdoutStr = result.stdout.toString().trim();
|
|
|
|
expect(stdoutStr, isNotEmpty);
|
|
|
|
final json = jsonDecode(stdoutStr);
|
|
expect(json['schemaVersion'], 1);
|
|
expect(json['type'], 'executionResult');
|
|
expect(json['success'], isTrue);
|
|
expect(json['exitCode'], 0);
|
|
|
|
expect(json['stepEvents'], isA<List>());
|
|
expect(json['stepEvents'].length, greaterThanOrEqualTo(2));
|
|
expect(json['stepEvents'][0]['type'], 'stepEvent');
|
|
expect(json['stepEvents'][0]['event'], 'started');
|
|
expect(json['stepEvents'][0]['commandId'], 'hello');
|
|
expect(json['stepEvents'].last['event'], 'completed');
|
|
expect(json['stepEvents'].last['commandId'], 'hello');
|
|
|
|
expect(stdoutStr, isNot(contains('Execute command:')));
|
|
expect(stdoutStr, isNot(contains('hi')));
|
|
} finally {
|
|
if (tempFile.existsSync()) {
|
|
tempFile.deleteSync();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
group('CliRunnerOutputPort style→color mapping', () {
|
|
// Reaching private _colorFor via a public call and asserting the emitted output.
|
|
test('progress style emits via printer (verifies mapping chain)', () async {
|
|
final actualColors = <Color?>[];
|
|
final adapterWithCapture = CliRunnerOutputPort(
|
|
enabled: () => true,
|
|
leadingPrefix: () => '',
|
|
println: (value, {color, background}) async {
|
|
actualColors.add(color);
|
|
},
|
|
printString: (value, {color, background}) async {
|
|
actualColors.add(color);
|
|
},
|
|
);
|
|
|
|
await adapterWithCapture.line(
|
|
'test line',
|
|
style: RunnerOutputStyle.progress,
|
|
);
|
|
await adapterWithCapture.block(
|
|
'test block',
|
|
style: RunnerOutputStyle.progress,
|
|
);
|
|
await adapterWithCapture.buildStep(
|
|
'test step',
|
|
style: RunnerOutputStyle.progress,
|
|
);
|
|
|
|
// progress → cyan 매핑 검증: line/println/adapter output에서 cyan이 기록되어야 함
|
|
expect(actualColors, contains(Color.cyan));
|
|
});
|
|
|
|
test('all styles map to expected colors through adapter', () async {
|
|
for (var style in RunnerOutputStyle.values) {
|
|
// Each call records exactly one color.
|
|
final single = <Color?>[];
|
|
final tempAdapter = CliRunnerOutputPort(
|
|
enabled: () => true,
|
|
leadingPrefix: () => '',
|
|
println: (value, {color, background}) async {
|
|
single.add(color);
|
|
},
|
|
printString: (value, {color, background}) async {
|
|
single.add(color);
|
|
},
|
|
);
|
|
await tempAdapter.line('x', style: style);
|
|
await tempAdapter.block('x', style: style);
|
|
await tempAdapter.buildStep('x', style: style);
|
|
|
|
// 각 스타일은 null이 아닌 유색 매핑이어야 함 (normal 제외)
|
|
if (style != RunnerOutputStyle.normal) {
|
|
expect(
|
|
single,
|
|
everyElement(isNotNull),
|
|
reason: 'style $style must not be null',
|
|
);
|
|
}
|
|
}
|
|
// 특정 style→color 매핑 검증
|
|
final progressAdapter = CliRunnerOutputPort(
|
|
enabled: () => true,
|
|
leadingPrefix: () => '',
|
|
println: (value, {color, background}) async {},
|
|
printString: (value, {color, background}) async {
|
|
if (value.contains('test')) expect(color, equals(Color.cyan));
|
|
},
|
|
);
|
|
await progressAdapter.buildStep(
|
|
'test',
|
|
style: RunnerOutputStyle.progress,
|
|
);
|
|
|
|
final successAdapter = CliRunnerOutputPort(
|
|
enabled: () => true,
|
|
leadingPrefix: () => '',
|
|
println: (value, {color, background}) async {},
|
|
printString: (value, {color, background}) async {
|
|
if (value.contains('test2')) expect(color, equals(Color.green));
|
|
},
|
|
);
|
|
await successAdapter.buildStep('test2', style: RunnerOutputStyle.success);
|
|
});
|
|
});
|
|
}
|
|
|
|
class _RecordingOutputPort implements RunnerOutputPort {
|
|
final List<String> lines = [];
|
|
final List<String> blocks = [];
|
|
final List<String> steps = [];
|
|
final List<RunnerOutputStyle> stepStyles = [];
|
|
|
|
@override
|
|
Future<void> line(
|
|
String message, {
|
|
RunnerOutputStyle style = RunnerOutputStyle.normal,
|
|
}) async {
|
|
lines.add(message);
|
|
}
|
|
|
|
@override
|
|
Future<void> block(
|
|
String message, {
|
|
RunnerOutputStyle style = RunnerOutputStyle.normal,
|
|
}) async {
|
|
blocks.add(message);
|
|
}
|
|
|
|
@override
|
|
Future<void> buildStep(
|
|
String name, {
|
|
RunnerOutputStyle style = RunnerOutputStyle.success,
|
|
}) async {
|
|
steps.add(name);
|
|
stepStyles.add(style);
|
|
}
|
|
}
|