core data composition과 Application 출력 경계에서 CLI Color 의존을 분리하고, 기존 CLI 출력 의미를 adapter 테스트로 보존하기 위해 변경한다.
679 lines
20 KiB
Dart
679 lines
20 KiB
Dart
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('<!property.items>');
|
|
expect(listValue, isA<List>());
|
|
expect(listValue, equals([1, 2, 3]));
|
|
|
|
final mapValue = Command.replaceTagValue('<!property.config>');
|
|
expect(mapValue, isA<Map>());
|
|
expect(mapValue, equals({'a': 1}));
|
|
|
|
final embedded = Command.replaceTagValue('values=<!property.items>');
|
|
expect(embedded, isA<String>());
|
|
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<String, dynamic> props,
|
|
List<String> 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': '<!property.env> == 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'] = <dynamic>[];
|
|
final ctx = makeCtx(
|
|
{
|
|
'items': [10, 20, 30],
|
|
},
|
|
['print'],
|
|
);
|
|
|
|
final result = Pipeline.pipelineInitialize([
|
|
{
|
|
'foreach': {
|
|
'iterator': '<!property.items>',
|
|
'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': '<!property.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': '<!property.env>',
|
|
'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': '<!property.running>',
|
|
'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': '<!state.buildStep>'},
|
|
]);
|
|
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': '<!property.items>',
|
|
'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<Exception>().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<NoopRunnerOutputPort>());
|
|
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<NoopRunnerOutputPort>());
|
|
},
|
|
);
|
|
|
|
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': '<!property.items>',
|
|
'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<NoopRunnerOutputPort>());
|
|
},
|
|
);
|
|
}
|
|
|
|
class ThrowingExecutor extends PipelineExecutor {
|
|
@override
|
|
PipelineValidateResult initialize(Map<String, dynamic> set) =>
|
|
PipelineValidateResult();
|
|
|
|
@override
|
|
Future execute() => Future.error(Exception('Test error message'));
|
|
}
|
|
|
|
class _RecordingOutputPort implements RunnerOutputPort {
|
|
final List<String> lines = [];
|
|
final List<String> blocks = [];
|
|
final List<String> steps = [];
|
|
|
|
@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);
|
|
}
|
|
}
|