runner 출력 경계 분리의 첫 계약 작업을 완료하고 후속 subtask와 로드맵 상태를 함께 기록한다. workspace 포트와 local smoke 기준도 독립 Control Plane 방향에 맞춰 정리한다.
610 lines
18 KiB
Dart
610 lines
18 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 = {};
|
|
});
|
|
|
|
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'));
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|