import 'package:oto/oto/application.dart'; import 'package:oto/oto/commands/command.dart'; import 'package:oto/oto/commands/command_registry.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:oto/oto/pipeline/pipeline.dart'; import 'package:oto/oto/pipeline/pipeline_exe.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('parses minimal build yaml into DataBuild', () { const yaml = ''' property: workspace: . commands: - command: Print id: hello param: hi pipeline: id: main workflow: - exe: hello '''; final map = Application.getMapFromYamlA(yaml); expect(map, isNotNull); final build = DataBuild.fromJson(map!); expect(build.property!['workspace'], '.'); expect(build.commands, hasLength(1)); expect(build.commands.first.id, 'hello'); expect(build.commands.first.command, CommandType.Print); expect(build.pipeline!.id, 'main'); expect(build.pipeline!.workflow, hasLength(1)); }); test('tag system preserves single tag object values', () { Application.instance.property['items'] = [1, 2, 3]; Application.instance.property['config'] = {'a': 1}; final listValue = Command.replaceTagValue(''); expect(listValue, isA()); expect(listValue, equals([1, 2, 3])); final mapValue = Command.replaceTagValue(''); expect(mapValue, isA()); expect(mapValue, equals({'a': 1})); final embedded = Command.replaceTagValue('values='); expect(embedded, isA()); expect(embedded, contains('1')); }); test('pipeline validation rejects missing command id', () { final result = Pipeline.pipelineInitialize([ {'exe': 'doesNotExist'}, ]); expect(result.enable, isFalse); expect(result.message, contains('doesNotExist')); }); // ── Explicit context regression tests ────────────────────────────────────── // Each test puts a different value in the singleton vs. the explicit context // and asserts that flow-control uses the explicit context exclusively. ExecutionContext makeCtx( Map props, List commandIds, ) { registerAllCommands(); final ctx = ExecutionContext(); ctx.property.addAll(props); for (final id in commandIds) { final cmd = DataCommand() ..command = CommandType.Print ..id = id ..param = {'message': id}; ctx.dataCommandMap[id] = cmd; } return ctx; } test( 'PipelineIf evaluates condition from explicit context not singleton', () async { // Singleton: env = 'dev' → on-false; explicit: env = 'prod' → on-true Application.instance.property['env'] = 'dev'; final ctx = makeCtx({'env': 'prod'}, ['onTrue', 'onFalse']); final result = Pipeline.pipelineInitialize([ { 'if': { 'condition-string': ' == prod', 'on-true': [ {'exe': 'onTrue'}, ], 'on-false': [ {'exe': 'onFalse'}, ], }, }, ], context: ctx); expect(result.enable, isTrue, reason: result.message); await result.pipeline!.execute(); expect(ctx.commandStates['onTrue'], CommandState.complete); expect(ctx.commandStates['onFalse'], CommandState.ready); }, ); test( 'PipelineForeach iterates list from explicit context not singleton', () async { // Singleton: items = [] (empty); explicit: items = [10, 20, 30] Application.instance.property['items'] = []; final ctx = makeCtx( { 'items': [10, 20, 30], }, ['print'], ); final result = Pipeline.pipelineInitialize([ { 'foreach': { 'iterator': '', 'setValue': '<@property.current>', 'on-do': [ {'exe': 'print'}, ], }, }, ], context: ctx); expect(result.enable, isTrue, reason: result.message); await result.pipeline!.execute(); // Loop ran 3 times using explicit context; last assigned value = 30 expect(ctx.property['current'], 30); // Singleton must not have been modified expect(Application.instance.property.containsKey('current'), isFalse); }, ); test( 'PipelineContain evaluates target from explicit context not singleton', () async { // Singleton: target = 'xyz' (not in list); explicit: target = 'foo' (in list) Application.instance.property['target'] = 'xyz'; final ctx = makeCtx({'target': 'foo'}, ['onTrue', 'onFalse']); final result = Pipeline.pipelineInitialize([ { 'contain': { 'target': '', 'list': ['foo', 'bar'], 'on-true': [ {'exe': 'onTrue'}, ], 'on-false': [ {'exe': 'onFalse'}, ], }, }, ], context: ctx); expect(result.enable, isTrue, reason: result.message); await result.pipeline!.execute(); expect(ctx.commandStates['onTrue'], CommandState.complete); expect(ctx.commandStates['onFalse'], CommandState.ready); }, ); // REVIEW_VALIDATE-2: malformed nested pipeline regression tests test('pipeline validation rejects if with no condition field', () { final result = Pipeline.pipelineInitialize([ { 'if': {'on-true': []}, }, ]); expect(result.enable, isFalse); expect(result.message, isNotNull); expect(result.message, isNot(contains('is not a subtype'))); }); test('pipeline validation rejects foreach with non-string iterator', () { final result = Pipeline.pipelineInitialize([ { 'foreach': { 'iterator': 123, 'setValue': '<@property.item>', 'on-do': [ {'exe': 'someCmd'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, isNotNull); expect(result.message, contains('foreach')); expect(result.message, isNot(contains('is not a subtype'))); }); test('pipeline validation rejects switch with non-list cases', () { final result = Pipeline.pipelineInitialize([ { 'switch': {'condition-string': 'test', 'cases': 'not-a-list'}, }, ]); expect(result.enable, isFalse); expect(result.message, isNotNull); expect(result.message, contains('switch')); expect(result.message, isNot(contains('is not a subtype'))); }); test('pipeline validation rejects if condition without operator', () { final result = Pipeline.pipelineInitialize([ { 'if': { 'condition-string': '', 'on-true': [ {'exe': 'hello'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, contains('supported operator')); expect(result.message, isNot(contains('LateInitializationError'))); }); test('pipeline validation rejects while condition without operator', () { final result = Pipeline.pipelineInitialize([ { 'while': { 'condition-bool': '', 'on-do': [ {'exe': 'hello'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, contains('supported operator')); expect(result.message, isNot(contains('LateInitializationError'))); }); test('pipeline validation rejects wait-until condition without operator', () { final result = Pipeline.pipelineInitialize([ {'wait-until-string': ''}, ]); expect(result.enable, isFalse); expect(result.message, contains('supported operator')); expect(result.message, isNot(contains('LateInitializationError'))); }); // REVIEW_REVIEW_VALIDATE-3: branch list field validation regression tests test('pipeline validation rejects exe-handle with non-list on-success', () { final result = Pipeline.pipelineInitialize([ { 'exe-handle': { 'id': 'hello', 'on-success': 'not-a-list', 'on-fail': [ {'exe': 'hello'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('exe-handle')); expect(result.message, contains('on-success')); }); test('pipeline validation rejects exe-handle with non-list on-fail', () { final result = Pipeline.pipelineInitialize([ { 'exe-handle': { 'id': 'hello', 'on-success': [ {'exe': 'hello'}, ], 'on-fail': 'not-a-list', }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('exe-handle')); expect(result.message, contains('on-fail')); }); test('pipeline validation rejects if with non-list on-true', () { final result = Pipeline.pipelineInitialize([ { 'if': {'condition-string': 'a == b', 'on-true': 'not-a-list'}, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('if')); expect(result.message, contains('on-true')); }); test('pipeline validation rejects if with non-list on-false', () { final result = Pipeline.pipelineInitialize([ { 'if': { 'condition-string': 'a == b', 'on-true': [ {'exe': 'hello'}, ], 'on-false': 'not-a-list', }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('if')); expect(result.message, contains('on-false')); }); test('pipeline validation rejects while with non-list on-do', () { final result = Pipeline.pipelineInitialize([ { 'while': {'condition-bool': 'true', 'on-do': 'not-a-list'}, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('while')); expect(result.message, contains('on-do')); }); test('pipeline validation rejects foreach with non-list on-do', () { final result = Pipeline.pipelineInitialize([ { 'foreach': { 'iterator': '', 'setValue': '<@property.item>', 'on-do': 'not-a-list', }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('foreach')); expect(result.message, contains('on-do')); }); test('pipeline validation rejects switch with non-map case element', () { final result = Pipeline.pipelineInitialize([ { 'switch': { 'condition-string': 'prod', 'cases': ['not-a-map'], }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('switch')); expect(result.message, contains('cases')); }); test('pipeline validation rejects switch with missing tasks in case', () { final result = Pipeline.pipelineInitialize([ { 'switch': { 'condition-string': 'prod', 'cases': [ {'value': 'prod'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('switch')); expect(result.message, contains('tasks')); }); test('pipeline validation rejects switch with non-list tasks in case', () { final result = Pipeline.pipelineInitialize([ { 'switch': { 'condition-string': 'prod', 'cases': [ {'value': 'prod', 'tasks': 'not-a-list'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('switch')); expect(result.message, contains('tasks')); }); // REVIEW_REVIEW_VALIDATE-2: contain node guard regression tests test('pipeline validation rejects contain with non-map value', () { final result = Pipeline.pipelineInitialize([ {'contain': 123}, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('contain')); }); test('pipeline validation rejects contain with missing target', () { final result = Pipeline.pipelineInitialize([ { 'contain': { 'list': ['a', 'b'], 'on-true': [ {'exe': 'x'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('contain')); expect(result.message, contains('target')); }); test('pipeline validation rejects contain with non-string target', () { final result = Pipeline.pipelineInitialize([ { 'contain': { 'target': 123, 'list': ['a', 'b'], 'on-true': [ {'exe': 'x'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('contain')); expect(result.message, contains('target')); }); test('pipeline validation rejects contain with missing list', () { final result = Pipeline.pipelineInitialize([ { 'contain': { 'target': 'foo', 'on-true': [ {'exe': 'x'}, ], }, }, ]); expect(result.enable, isFalse); expect(result.message, isNot(contains('is not a subtype'))); expect(result.message, contains('contain')); expect(result.message, contains('list')); }); test( 'PipelineSwitch validate uses explicit context command map not singleton', () { // Singleton dataCommandMap is empty; only explicit context has the command. registerAllCommands(); final ctx = ExecutionContext(); final cmd = DataCommand() ..command = CommandType.Print ..id = 'deployProd' ..param = {'message': 'prod'}; ctx.dataCommandMap['deployProd'] = cmd; final result = Pipeline.pipelineInitialize([ { 'switch': { 'condition-string': 'prod', 'cases': [ { 'value': 'prod', 'tasks': [ {'exe': 'deployProd'}, ], }, ], }, }, ], context: ctx); // Should succeed: deployProd exists in explicit context. // Would fail if singleton (empty) context were used. expect(result.enable, isTrue, reason: result.message); }, ); test( 'pipeline execution records started and completed step events', () async { final ctx = makeCtx({}, ['hello']); final result = Pipeline.pipelineInitialize([ {'exe': 'hello'}, ], context: ctx); expect(result.enable, isTrue); final pipeline = result.pipeline!; await pipeline.execute(); expect(ctx.stepEvents, hasLength(2)); final started = ctx.stepEvents[0]; final completed = ctx.stepEvents[1]; expect(started.event, 'started'); expect(started.stepId, 0); expect(started.workflowIndex, 0); expect(started.stepType, 'exe'); expect(started.commandId, 'hello'); expect(started.commandType, 'Print'); expect(completed.event, 'completed'); expect(completed.stepId, 0); expect(completed.workflowIndex, 0); expect(completed.stepType, 'exe'); expect(completed.commandId, 'hello'); expect(completed.commandType, 'Print'); }, ); test('pipeline execution records failed step event before rethrow', () async { final ctx = ExecutionContext(); final throwing = ThrowingExecutor() ..context = ctx ..workflowKey = 'exe'; final pipeline = Pipeline([throwing], ctx); await expectLater( pipeline.execute(), throwsA( isA().having( (e) => e.toString(), 'message', contains('Test error message'), ), ), ); expect(ctx.stepEvents, hasLength(2)); final started = ctx.stepEvents[0]; final failed = ctx.stepEvents[1]; expect(started.event, 'started'); expect(started.stepId, 0); expect(failed.event, 'failed'); expect(failed.stepId, 0); expect(failed.stepType, 'exe'); expect(failed.error, isNotNull); expect(failed.error!['message'], contains('Test error message')); }); test('ExecutionContext exposes no-op runner output port', () async { final ctx = ExecutionContext(); expect(ctx.output, isA()); await expectLater(ctx.output.line('hello'), completes); await expectLater( ctx.output.block('block', style: RunnerOutputStyle.success), completes, ); await expectLater( ctx.output.buildStep('step', style: RunnerOutputStyle.accent), completes, ); final recording = _RecordingOutputPort(); ctx.output = recording; await ctx.output.line('msg', style: RunnerOutputStyle.warning); expect(recording.lines, contains('msg')); }); test( 'pipeline emits progress through explicit context output port', () async { final ctx = makeCtx({}, ['hello']); final recording = _RecordingOutputPort(); ctx.output = recording; final result = Pipeline.pipelineInitialize([ {'exe': 'hello'}, ], context: ctx); expect(result.enable, isTrue, reason: result.message); await result.pipeline!.execute(); expect(recording.steps, anyElement(contains('hello'))); expect(Application.instance.context.output, isA()); }, ); test( 'PipelineForeach emits completion through explicit context output port', () async { final ctx = makeCtx( { 'items': [1, 2], }, ['print'], ); final recording = _RecordingOutputPort(); ctx.output = recording; final result = Pipeline.pipelineInitialize([ { 'foreach': { 'iterator': '', 'setValue': '<@property.current>', 'on-do': [ {'exe': 'print'}, ], }, }, ], context: ctx); expect(result.enable, isTrue, reason: result.message); await result.pipeline!.execute(); expect(recording.lines, anyElement(contains('Foreach'))); expect(Application.instance.context.output, isA()); }, ); } class ThrowingExecutor extends PipelineExecutor { @override PipelineValidateResult initialize(Map set) => PipelineValidateResult(); @override Future execute() => Future.error(Exception('Test error message')); } class _RecordingOutputPort implements RunnerOutputPort { final List lines = []; final List blocks = []; final List steps = []; @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); } }