package executor import ( "context" "path/filepath" "testing" "github.com/j3ssie/osmedeus/v5/internal/config" "github.com/j3ssie/osmedeus/v5/internal/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // testConfig returns a config with isolated temp directories that are // automatically cleaned up after the test completes. func testConfig(t *testing.T) *config.Config { t.Helper() baseDir := t.TempDir() return &config.Config{ BaseFolder: baseDir, WorkspacesPath: filepath.Join(baseDir, "workspaces"), WorkflowsPath: filepath.Join(baseDir, "workflows"), BinariesPath: filepath.Join(baseDir, "binaries"), DataPath: filepath.Join(baseDir, "data"), } } func TestExecutor_New(t *testing.T) { executor := NewExecutor() assert.NotNil(t, executor) } func TestExecutor_BashStep(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-bash", Kind: core.KindModule, Steps: []core.Step{ { Name: "echo-test", Type: core.StepTypeBash, Command: "echo hello", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 1) assert.Equal(t, core.StepStatusSuccess, result.Steps[0].Status) } func TestExecutor_DryRun(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-dryrun", Kind: core.KindModule, Steps: []core.Step{ { Name: "dangerous-command", Type: core.StepTypeBash, Command: "rm -rf /nonexistent", }, }, } executor := NewExecutor() executor.SetDryRun(true) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Contains(t, result.Steps[0].Output, "DRY-RUN") } func TestExecutor_RequiredParams(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-params", Kind: core.KindModule, Params: []core.Param{ { Name: "required_param", Required: true, }, }, Steps: []core.Step{ { Name: "test-step", Type: core.StepTypeBash, Command: "echo test", }, }, } executor := NewExecutor() executor.SetDryRun(true) executor.SetSpinner(false) // Should fail without required param _, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "required_param") } func TestExecutor_DependencyTargetTypes_PassAny(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-target-types", Kind: core.KindModule, Dependencies: &core.Dependencies{ TargetTypes: []core.TargetType{core.TargetTypeDomain, core.TargetTypeURL}, }, Steps: []core.Step{ { Name: "echo-test", Type: core.StepTypeBash, Command: "echo ok", }, }, } executor := NewExecutor() executor.SetDryRun(true) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "example.com", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) } func TestExecutor_DependencyTargetTypes_Fail(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-target-types-fail", Kind: core.KindModule, Dependencies: &core.Dependencies{ TargetTypes: []core.TargetType{core.TargetTypeDomain, core.TargetTypeURL}, }, Steps: []core.Step{ { Name: "echo-test", Type: core.StepTypeBash, Command: "echo ok", }, }, } executor := NewExecutor() executor.SetDryRun(true) executor.SetSpinner(false) _, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "not-a-domain", }, cfg) require.Error(t, err) assert.Contains(t, err.Error(), "target") assert.Contains(t, err.Error(), "required types") } func TestExecutor_DependencyTargetTypes_Unknown(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-target-types-unknown", Kind: core.KindModule, Dependencies: &core.Dependencies{ TargetTypes: []core.TargetType{core.TargetType("unknown")}, }, Steps: []core.Step{ { Name: "echo-test", Type: core.StepTypeBash, Command: "echo ok", }, }, } executor := NewExecutor() executor.SetDryRun(true) executor.SetSpinner(false) _, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "example.com", }, cfg) require.Error(t, err) assert.Contains(t, err.Error(), "unknown target_types") } func TestExecutor_DefaultParams(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-default-params", Kind: core.KindModule, Params: []core.Param{ { Name: "my_param", Default: "default_value", }, }, Steps: []core.Step{ { Name: "test-step", Type: core.StepTypeBash, Command: "echo {{my_param}}", }, }, } executor := NewExecutor() executor.SetDryRun(true) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) } func TestExecutor_FlowKindCheck(t *testing.T) { ctx := context.Background() cfg := testConfig(t) // Create a flow workflow flow := &core.Workflow{ Name: "test-flow", Kind: core.KindFlow, } executor := NewExecutor() // ExecuteModule should fail for flow _, err := executor.ExecuteModule(ctx, flow, map[string]string{ "target": "test", }, cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "not a module") } func TestExecutor_MultipleSteps(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-multiple-steps", Kind: core.KindModule, Steps: []core.Step{ { Name: "step-1", Type: core.StepTypeBash, Command: "echo step1", }, { Name: "step-2", Type: core.StepTypeBash, Command: "echo step2", }, { Name: "step-3", Type: core.StepTypeBash, Command: "echo step3", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 3) for _, step := range result.Steps { assert.Equal(t, core.StepStatusSuccess, step.Status) } } // Parallel Commands Tests func TestExecutor_ParallelCommands(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-parallel-commands", Kind: core.KindModule, Steps: []core.Step{ { Name: "parallel-echo", Type: core.StepTypeBash, ParallelCommands: []string{ "echo 'command 1'", "echo 'command 2'", "echo 'command 3'", }, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 1) assert.Equal(t, core.StepStatusSuccess, result.Steps[0].Status) // Verify all parallel outputs are captured assert.Contains(t, result.Steps[0].Output, "command 1") assert.Contains(t, result.Steps[0].Output, "command 2") assert.Contains(t, result.Steps[0].Output, "command 3") } func TestExecutor_ParallelCommands_OneFailure(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-parallel-commands-fail", Kind: core.KindModule, Steps: []core.Step{ { Name: "parallel-with-failure", Type: core.StepTypeBash, ParallelCommands: []string{ "echo 'success 1'", "exit 1", "echo 'success 2'", }, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) // The workflow returns error when a step fails, but result still contains step info assert.Error(t, err) assert.NotNil(t, result) assert.Len(t, result.Steps, 1) // Step fails because one of the parallel commands failed assert.Equal(t, core.StepStatusFailed, result.Steps[0].Status) } // Parallel Functions Tests func TestExecutor_ParallelFunctions(t *testing.T) { t.Skip("parallel_functions is not yet implemented") ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-parallel-functions", Kind: core.KindModule, Steps: []core.Step{ { Name: "parallel-funcs", Type: core.StepTypeFunction, ParallelFunctions: []string{ "trim(\" hello \")", "contains(\"hello world\", \"world\")", }, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 1) assert.Equal(t, core.StepStatusSuccess, result.Steps[0].Status) } // Parallel Steps Tests func TestExecutor_ParallelSteps(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-parallel-steps", Kind: core.KindModule, Steps: []core.Step{ { Name: "nested-parallel", Type: core.StepTypeParallel, ParallelSteps: []core.Step{ { Name: "sub-step-1", Type: core.StepTypeBash, Command: "echo 'sub 1'", }, { Name: "sub-step-2", Type: core.StepTypeBash, Command: "echo 'sub 2'", }, }, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 1) assert.Equal(t, core.StepStatusSuccess, result.Steps[0].Status) // Verify outputs from sub-steps are captured assert.Contains(t, result.Steps[0].Output, "sub 1") assert.Contains(t, result.Steps[0].Output, "sub 2") } func TestExecutor_ParallelSteps_MixedTypes(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-parallel-steps-mixed", Kind: core.KindModule, Steps: []core.Step{ { Name: "mixed-parallel", Type: core.StepTypeParallel, ParallelSteps: []core.Step{ { Name: "bash-sub", Type: core.StepTypeBash, Command: "echo 'bash output'", }, { Name: "func-sub", Type: core.StepTypeFunction, Function: "trim(\" hello \")", }, }, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 1) assert.Equal(t, core.StepStatusSuccess, result.Steps[0].Status) } // Timeout Tests func TestExecutor_StepTimeout(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-timeout", Kind: core.KindModule, Steps: []core.Step{ { Name: "quick-command", Type: core.StepTypeBash, Command: "echo 'fast'", Timeout: core.StepTimeout("5"), }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 1) assert.Equal(t, core.StepStatusSuccess, result.Steps[0].Status) } func TestExecutor_StepTimeout_Exceeds(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-timeout-exceed", Kind: core.KindModule, Steps: []core.Step{ { Name: "slow-command", Type: core.StepTypeBash, Command: "sleep 10", Timeout: core.StepTimeout("1s"), }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) // The workflow returns error when step fails due to timeout assert.Error(t, err) assert.NotNil(t, result) assert.Len(t, result.Steps, 1) assert.Equal(t, core.StepStatusFailed, result.Steps[0].Status) } func TestExecutor_StepTimeout_TemplateDuration(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-timeout-template", Kind: core.KindModule, Params: []core.Param{ {Name: "timeout", Default: "1s"}, }, Steps: []core.Step{ { Name: "slow-command", Type: core.StepTypeBash, Command: "sleep 10", Timeout: core.StepTimeout("{{timeout}}"), }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", "timeout": "1s", }, cfg) assert.Error(t, err) assert.NotNil(t, result) assert.Len(t, result.Steps, 1) assert.Equal(t, core.StepStatusFailed, result.Steps[0].Status) } func TestExecutor_Foreach_ThreadsTemplate(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-foreach-threads-template", Kind: core.KindModule, Steps: []core.Step{ { Name: "create-input", Type: core.StepTypeBash, Commands: []string{ "mkdir -p {{Output}}", "printf 'one\ntwo\nthree\n' > {{Output}}/items.txt", }, }, { Name: "process-items", Type: core.StepTypeForeach, Input: "{{Output}}/items.txt", Variable: "item", Threads: core.StepThreads("{{ baseThreads * 2 }}"), Step: &core.Step{ Name: "process-item", Type: core.StepTypeBash, Command: "echo [[item]]", }, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 2) assert.Equal(t, core.StepStatusSuccess, result.Steps[0].Status) assert.Equal(t, core.StepStatusSuccess, result.Steps[1].Status) assert.Contains(t, result.Steps[1].Output, "one") } func TestExecutor_StepTimeout_ParallelCommands(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-timeout-parallel", Kind: core.KindModule, Steps: []core.Step{ { Name: "parallel-with-timeout", Type: core.StepTypeBash, ParallelCommands: []string{ "echo 'fast 1'", "sleep 10", "echo 'fast 2'", }, Timeout: core.StepTimeout("1"), }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) // Workflow returns error when step fails due to timeout on one of the parallel commands assert.Error(t, err) assert.NotNil(t, result) assert.Len(t, result.Steps, 1) // One of the parallel commands times out assert.Equal(t, core.StepStatusFailed, result.Steps[0].Status) } // Decision Tests // Note: Decision routing may require additional setup or different condition syntax. // These tests verify the basic decision structure is processed without errors. func TestExecutor_Decision_SkipToEnd(t *testing.T) { t.Skip("Decision routing not working as expected - needs investigation") ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-skip", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-condition", Type: core.StepTypeBash, Command: "echo 'running first step'", Decision: &core.DecisionConfig{ Switch: "{{target}}", Cases: map[string]core.DecisionCase{ "skip": {Goto: "_end"}, }, }, }, { Name: "should-not-run", Type: core.StepTypeBash, Command: "echo 'should not run'", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "skip", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // Only the first step should have run assert.Len(t, result.Steps, 1) assert.Equal(t, "check-condition", result.Steps[0].StepName) } func TestExecutor_Decision_JumpToStep(t *testing.T) { t.Skip("Decision routing not working as expected - needs investigation") ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-jump", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-condition", Type: core.StepTypeBash, Command: "echo 'jumping'", Decision: &core.DecisionConfig{ Switch: "{{target}}", Cases: map[string]core.DecisionCase{ "jump": {Goto: "final-step"}, }, }, }, { Name: "middle-step", Type: core.StepTypeBash, Command: "echo 'middle'", }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'final'", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "jump", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // Should have skipped middle-step and jumped to final-step assert.Len(t, result.Steps, 2) assert.Equal(t, "check-condition", result.Steps[0].StepName) assert.Equal(t, "final-step", result.Steps[1].StepName) } func TestExecutor_Decision_NoMatch(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-no-match", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-condition", Type: core.StepTypeBash, Command: "echo 'continue'", Decision: &core.DecisionConfig{ Switch: "{{target}}", Cases: map[string]core.DecisionCase{ "skip": {Goto: "_end"}, "jump": {Goto: "final-step"}, }, }, }, { Name: "next-step", Type: core.StepTypeBash, Command: "echo 'next'", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "continue", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // No decision matched, so should continue to next step assert.Len(t, result.Steps, 2) assert.Equal(t, "check-condition", result.Steps[0].StepName) assert.Equal(t, "next-step", result.Steps[1].StepName) } func TestExecutor_Decision_MultipleCases(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-multi", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-condition", Type: core.StepTypeBash, Command: "echo 'checking'", Decision: &core.DecisionConfig{ Switch: "{{target}}", Cases: map[string]core.DecisionCase{ "first": {Goto: "step-a"}, "second": {Goto: "step-b"}, }, }, }, { Name: "step-a", Type: core.StepTypeBash, Command: "echo 'step a'", }, { Name: "step-b", Type: core.StepTypeBash, Command: "echo 'step b'", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "first", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // First case matches, should jump to step-a assert.GreaterOrEqual(t, len(result.Steps), 2) assert.Equal(t, "check-condition", result.Steps[0].StepName) assert.Equal(t, "step-a", result.Steps[1].StepName) } // Tests for Switch/Case decision syntax func TestExecutor_Decision_SwitchCase_Match(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-switch", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'domain'", Exports: map[string]string{ "detected_type": "domain", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Goto: "domain-scan"}, "ip": {Goto: "ip-scan"}, "cidr": {Goto: "cidr-scan"}, }, Default: &core.DecisionCase{Goto: "generic-scan"}, }, }, { Name: "generic-scan", Type: core.StepTypeBash, Command: "echo 'generic'", }, { Name: "domain-scan", Type: core.StepTypeBash, Command: "echo 'domain scan'", }, { Name: "ip-scan", Type: core.StepTypeBash, Command: "echo 'ip scan'", }, { Name: "cidr-scan", Type: core.StepTypeBash, Command: "echo 'cidr scan'", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{}, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // Switch matched "domain", should jump to domain-scan assert.GreaterOrEqual(t, len(result.Steps), 2) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "domain-scan", result.Steps[1].StepName) } func TestExecutor_Decision_SwitchCase_Default(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-switch-default", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'unknown'", Exports: map[string]string{ "detected_type": "unknown", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Goto: "domain-scan"}, "ip": {Goto: "ip-scan"}, }, Default: &core.DecisionCase{Goto: "generic-scan"}, }, }, { Name: "domain-scan", Type: core.StepTypeBash, Command: "echo 'domain scan'", }, { Name: "ip-scan", Type: core.StepTypeBash, Command: "echo 'ip scan'", }, { Name: "generic-scan", Type: core.StepTypeBash, Command: "echo 'generic scan'", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{}, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // No case matched, should fall through to default -> generic-scan assert.GreaterOrEqual(t, len(result.Steps), 2) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "generic-scan", result.Steps[1].StepName) } func TestExecutor_Decision_SwitchCase_NoMatchNoDefault(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-switch-no-default", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'unknown'", Exports: map[string]string{ "detected_type": "unknown", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Goto: "domain-scan"}, "ip": {Goto: "ip-scan"}, }, // No default }, }, { Name: "next-step", Type: core.StepTypeBash, Command: "echo 'next step'", }, { Name: "domain-scan", Type: core.StepTypeBash, Command: "echo 'domain scan'", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{}, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // No case matched and no default, should continue to next step assert.GreaterOrEqual(t, len(result.Steps), 2) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "next-step", result.Steps[1].StepName) } // Tests for Kahn's algorithm dependency graph (O(V+E) flow execution) func TestBuildDependencyGraph_NoDependencies(t *testing.T) { modules := []core.ModuleRef{ {Name: "module-a"}, {Name: "module-b"}, {Name: "module-c"}, } dependents, inDegree := buildDependencyGraph(modules) // All modules should have in-degree 0 (no dependencies) assert.Equal(t, 0, inDegree["module-a"]) assert.Equal(t, 0, inDegree["module-b"]) assert.Equal(t, 0, inDegree["module-c"]) // No dependents since nothing depends on anything assert.Empty(t, dependents["module-a"]) assert.Empty(t, dependents["module-b"]) assert.Empty(t, dependents["module-c"]) } func TestBuildDependencyGraph_LinearChain(t *testing.T) { // A -> B -> C -> D (linear dependency chain) modules := []core.ModuleRef{ {Name: "module-d", DependsOn: []string{"module-c"}}, {Name: "module-c", DependsOn: []string{"module-b"}}, {Name: "module-b", DependsOn: []string{"module-a"}}, {Name: "module-a"}, } dependents, inDegree := buildDependencyGraph(modules) // module-a has no dependencies assert.Equal(t, 0, inDegree["module-a"]) // module-b depends on module-a assert.Equal(t, 1, inDegree["module-b"]) // module-c depends on module-b assert.Equal(t, 1, inDegree["module-c"]) // module-d depends on module-c assert.Equal(t, 1, inDegree["module-d"]) // module-a has module-b as dependent assert.Contains(t, dependents["module-a"], "module-b") // module-b has module-c as dependent assert.Contains(t, dependents["module-b"], "module-c") // module-c has module-d as dependent assert.Contains(t, dependents["module-c"], "module-d") } func TestBuildDependencyGraph_Diamond(t *testing.T) { // Diamond pattern: A -> B, A -> C, B -> D, C -> D modules := []core.ModuleRef{ {Name: "module-a"}, {Name: "module-b", DependsOn: []string{"module-a"}}, {Name: "module-c", DependsOn: []string{"module-a"}}, {Name: "module-d", DependsOn: []string{"module-b", "module-c"}}, } dependents, inDegree := buildDependencyGraph(modules) // module-a has no dependencies assert.Equal(t, 0, inDegree["module-a"]) // module-b and module-c depend on module-a (1 each) assert.Equal(t, 1, inDegree["module-b"]) assert.Equal(t, 1, inDegree["module-c"]) // module-d depends on both module-b and module-c assert.Equal(t, 2, inDegree["module-d"]) // module-a has both module-b and module-c as dependents assert.Len(t, dependents["module-a"], 2) assert.Contains(t, dependents["module-a"], "module-b") assert.Contains(t, dependents["module-a"], "module-c") } func TestBuildModuleMap(t *testing.T) { modules := []core.ModuleRef{ {Name: "module-a", Path: "path/to/a.yaml"}, {Name: "module-b", Path: "path/to/b.yaml"}, {Name: "module-c", Path: "path/to/c.yaml"}, } moduleMap := buildModuleMap(modules) assert.Len(t, moduleMap, 3) assert.Equal(t, "path/to/a.yaml", moduleMap["module-a"].Path) assert.Equal(t, "path/to/b.yaml", moduleMap["module-b"].Path) assert.Equal(t, "path/to/c.yaml", moduleMap["module-c"].Path) } // Tests for CloneForLoop optimization func TestExecutionContext_CloneForLoop(t *testing.T) { // Create a context with some params and variables ctx := core.NewExecutionContext("test-workflow", core.KindModule, "run-123", "example.com") ctx.SetParam("param1", "value1") ctx.SetParam("param2", "value2") ctx.SetVariable("var1", "varvalue1") ctx.SetExport("export1", "exportvalue1") // Clone for loop iteration clone := ctx.CloneForLoop("item", "line-content", 5) // Verify metadata is copied assert.Equal(t, "test-workflow", clone.WorkflowName) assert.Equal(t, core.KindModule, clone.WorkflowKind) assert.Equal(t, "run-123", clone.RunID) assert.Equal(t, "example.com", clone.Target) // Verify Params reference is shared (not copied) // This is the key optimization - we share the immutable Params ctx.SetParam("new-param", "new-value") _, exists := clone.GetParam("new-param") assert.True(t, exists, "Clone should share Params reference with parent") // Verify loop variables are set loopVar, ok := clone.GetVariable("item") assert.True(t, ok) assert.Equal(t, "line-content", loopVar) iterID, ok := clone.GetVariable("_id_") assert.True(t, ok) assert.Equal(t, 5, iterID) // Verify parent variables are accessible v, ok := clone.GetVariable("var1") assert.True(t, ok) assert.Equal(t, "varvalue1", v) // Verify exports are isolated (clone has fresh exports map) _, exists = clone.GetExport("export1") assert.False(t, exists, "Clone should have fresh Exports map") } func TestExecutionContext_CloneForLoop_EmptyLoopVar(t *testing.T) { ctx := core.NewExecutionContext("test", core.KindModule, "run-1", "test.com") // Clone with empty loop variable (for parallel steps that don't need loop vars) clone := ctx.CloneForLoop("", nil, 0) // Should still set _id_ iterID, ok := clone.GetVariable("_id_") assert.True(t, ok) assert.Equal(t, 0, iterID) // Empty string key should not be set _, ok = clone.GetVariable("") assert.False(t, ok) } // Tests for step dependencies (DAG-style execution) func TestHasAnyStepDependencies(t *testing.T) { t.Run("no dependencies", func(t *testing.T) { steps := []core.Step{ {Name: "step-a", Type: core.StepTypeBash, Command: "echo a"}, {Name: "step-b", Type: core.StepTypeBash, Command: "echo b"}, } assert.False(t, hasAnyStepDependencies(steps)) }) t.Run("has dependencies", func(t *testing.T) { steps := []core.Step{ {Name: "step-a", Type: core.StepTypeBash, Command: "echo a"}, {Name: "step-b", Type: core.StepTypeBash, Command: "echo b", DependsOn: []string{"step-a"}}, } assert.True(t, hasAnyStepDependencies(steps)) }) } func TestBuildStepDependencyGraph(t *testing.T) { t.Run("no dependencies", func(t *testing.T) { steps := []core.Step{ {Name: "step-a"}, {Name: "step-b"}, {Name: "step-c"}, } dependents, inDegree := buildStepDependencyGraph(steps) assert.Equal(t, 0, inDegree["step-a"]) assert.Equal(t, 0, inDegree["step-b"]) assert.Equal(t, 0, inDegree["step-c"]) assert.Empty(t, dependents["step-a"]) assert.Empty(t, dependents["step-b"]) assert.Empty(t, dependents["step-c"]) }) t.Run("linear chain", func(t *testing.T) { // A -> B -> C steps := []core.Step{ {Name: "step-a"}, {Name: "step-b", DependsOn: []string{"step-a"}}, {Name: "step-c", DependsOn: []string{"step-b"}}, } dependents, inDegree := buildStepDependencyGraph(steps) assert.Equal(t, 0, inDegree["step-a"]) assert.Equal(t, 1, inDegree["step-b"]) assert.Equal(t, 1, inDegree["step-c"]) assert.Contains(t, dependents["step-a"], "step-b") assert.Contains(t, dependents["step-b"], "step-c") }) t.Run("diamond pattern", func(t *testing.T) { // A -> B, A -> C, B -> D, C -> D steps := []core.Step{ {Name: "step-a"}, {Name: "step-b", DependsOn: []string{"step-a"}}, {Name: "step-c", DependsOn: []string{"step-a"}}, {Name: "step-d", DependsOn: []string{"step-b", "step-c"}}, } dependents, inDegree := buildStepDependencyGraph(steps) assert.Equal(t, 0, inDegree["step-a"]) assert.Equal(t, 1, inDegree["step-b"]) assert.Equal(t, 1, inDegree["step-c"]) assert.Equal(t, 2, inDegree["step-d"]) assert.Len(t, dependents["step-a"], 2) assert.Contains(t, dependents["step-a"], "step-b") assert.Contains(t, dependents["step-a"], "step-c") }) } func TestBuildStepMap(t *testing.T) { steps := []core.Step{ {Name: "step-a", Type: core.StepTypeBash, Command: "echo a"}, {Name: "step-b", Type: core.StepTypeBash, Command: "echo b"}, {Name: "step-c", Type: core.StepTypeBash, Command: "echo c"}, } stepMap := buildStepMap(steps) assert.Len(t, stepMap, 3) assert.Equal(t, "echo a", stepMap["step-a"].Command) assert.Equal(t, "echo b", stepMap["step-b"].Command) assert.Equal(t, "echo c", stepMap["step-c"].Command) } func TestValidateStepDependencies(t *testing.T) { t.Run("valid dependencies", func(t *testing.T) { steps := []core.Step{ {Name: "step-a"}, {Name: "step-b", DependsOn: []string{"step-a"}}, {Name: "step-c", DependsOn: []string{"step-a", "step-b"}}, } err := validateStepDependencies(steps) assert.NoError(t, err) }) t.Run("invalid reference", func(t *testing.T) { steps := []core.Step{ {Name: "step-a"}, {Name: "step-b", DependsOn: []string{"nonexistent"}}, } err := validateStepDependencies(steps) assert.Error(t, err) assert.Contains(t, err.Error(), "non-existent step") assert.Contains(t, err.Error(), "nonexistent") }) } func TestDetectStepCycles(t *testing.T) { t.Run("no cycles", func(t *testing.T) { steps := []core.Step{ {Name: "step-a"}, {Name: "step-b", DependsOn: []string{"step-a"}}, {Name: "step-c", DependsOn: []string{"step-b"}}, } err := detectStepCycles(steps) assert.NoError(t, err) }) t.Run("self cycle", func(t *testing.T) { steps := []core.Step{ {Name: "step-a", DependsOn: []string{"step-a"}}, } err := detectStepCycles(steps) assert.Error(t, err) assert.Contains(t, err.Error(), "circular dependency") }) t.Run("two step cycle", func(t *testing.T) { steps := []core.Step{ {Name: "step-a", DependsOn: []string{"step-b"}}, {Name: "step-b", DependsOn: []string{"step-a"}}, } err := detectStepCycles(steps) assert.Error(t, err) assert.Contains(t, err.Error(), "circular dependency") }) t.Run("three step cycle", func(t *testing.T) { steps := []core.Step{ {Name: "step-a", DependsOn: []string{"step-c"}}, {Name: "step-b", DependsOn: []string{"step-a"}}, {Name: "step-c", DependsOn: []string{"step-b"}}, } err := detectStepCycles(steps) assert.Error(t, err) assert.Contains(t, err.Error(), "circular dependency") }) } func TestExecutor_StepDependencies_Diamond(t *testing.T) { ctx := context.Background() cfg := testConfig(t) // Diamond pattern: A -> B, A -> C, B -> D, C -> D module := &core.Workflow{ Name: "test-step-deps-diamond", Kind: core.KindModule, Steps: []core.Step{ { Name: "step-a", Type: core.StepTypeBash, Command: "echo A", }, { Name: "step-b", Type: core.StepTypeBash, Command: "echo B", DependsOn: []string{"step-a"}, }, { Name: "step-c", Type: core.StepTypeBash, Command: "echo C", DependsOn: []string{"step-a"}, }, { Name: "step-d", Type: core.StepTypeBash, Command: "echo D", DependsOn: []string{"step-b", "step-c"}, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 4) // All steps should have succeeded for _, step := range result.Steps { assert.Equal(t, core.StepStatusSuccess, step.Status) } } func TestExecutor_StepDependencies_NoDeps_Sequential(t *testing.T) { ctx := context.Background() cfg := testConfig(t) // No depends_on = sequential execution (backwards compatible) module := &core.Workflow{ Name: "test-no-deps-sequential", Kind: core.KindModule, Steps: []core.Step{ { Name: "step-a", Type: core.StepTypeBash, Command: "echo A", }, { Name: "step-b", Type: core.StepTypeBash, Command: "echo B", }, { Name: "step-c", Type: core.StepTypeBash, Command: "echo C", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) assert.Len(t, result.Steps, 3) // Verify steps ran in order (sequential) assert.Equal(t, "step-a", result.Steps[0].StepName) assert.Equal(t, "step-b", result.Steps[1].StepName) assert.Equal(t, "step-c", result.Steps[2].StepName) } func TestExecutor_StepDependencies_CircularDetection(t *testing.T) { ctx := context.Background() cfg := testConfig(t) // A -> B -> C -> A (circular) module := &core.Workflow{ Name: "test-circular-deps", Kind: core.KindModule, Steps: []core.Step{ { Name: "step-a", Type: core.StepTypeBash, Command: "echo A", DependsOn: []string{"step-c"}, }, { Name: "step-b", Type: core.StepTypeBash, Command: "echo B", DependsOn: []string{"step-a"}, }, { Name: "step-c", Type: core.StepTypeBash, Command: "echo C", DependsOn: []string{"step-b"}, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) _, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "circular dependency") } func TestExecutor_StepDependencies_InvalidRef(t *testing.T) { ctx := context.Background() cfg := testConfig(t) // depends_on references non-existent step module := &core.Workflow{ Name: "test-invalid-ref", Kind: core.KindModule, Steps: []core.Step{ { Name: "step-a", Type: core.StepTypeBash, Command: "echo A", }, { Name: "step-b", Type: core.StepTypeBash, Command: "echo B", DependsOn: []string{"nonexistent"}, }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) _, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "non-existent step") } func TestExecutor_StepDependencies_FailedDep_SkipsDependent(t *testing.T) { ctx := context.Background() cfg := testConfig(t) // A fails, B depends on A, B should be skipped module := &core.Workflow{ Name: "test-failed-dep", Kind: core.KindModule, Steps: []core.Step{ { Name: "step-a", Type: core.StepTypeBash, Command: "exit 1", // This will fail OnError: []core.Action{ {Action: core.ActionContinue}, // Continue on error so workflow doesn't abort }, }, { Name: "step-b", Type: core.StepTypeBash, Command: "echo B", DependsOn: []string{"step-a"}, }, { Name: "step-c", Type: core.StepTypeBash, Command: "echo C", // No dependency, should still run }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // Should have step-a (failed), step-c (success) // step-b depends on step-a which failed, so it should be skipped assert.GreaterOrEqual(t, len(result.Steps), 2) // Find step statuses var stepAStatus, stepBExists, stepCStatus core.StepStatus for _, step := range result.Steps { switch step.StepName { case "step-a": stepAStatus = step.Status case "step-b": stepBExists = step.Status case "step-c": stepCStatus = step.Status } } assert.Equal(t, core.StepStatusFailed, stepAStatus) assert.Equal(t, core.StepStatusSuccess, stepCStatus) // step-b should either not exist in results or be failed (skipped due to failed dependency) if stepBExists != "" { assert.NotEqual(t, core.StepStatusSuccess, stepBExists) } }