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/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 = {}; }); 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')); }); } class ThrowingExecutor extends PipelineExecutor { @override PipelineValidateResult initialize(Map set) => PipelineValidateResult(); @override Future execute() => Future.error(Exception('Test error message')); }