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 = []; 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 = []; 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 = []; 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()); 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 = []; 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 = []; 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 lines = []; final List blocks = []; final List steps = []; final List stepStyles = []; @override Future line( String message, { RunnerOutputStyle style = RunnerOutputStyle.normal, }) async { lines.add(message); } @override Future block( String message, { RunnerOutputStyle style = RunnerOutputStyle.normal, }) async { blocks.add(message); } @override Future buildStep( String name, { RunnerOutputStyle style = RunnerOutputStyle.success, }) async { steps.add(name); stepStyles.add(style); } }