package executor import ( "context" "path/filepath" "testing" "github.com/j3ssie/osmedeus/v5/internal/config" "github.com/j3ssie/osmedeus/v5/internal/core" "github.com/j3ssie/osmedeus/v5/internal/parser" "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) var ttmErr *TargetTypeMismatchError require.ErrorAs(t, err, &ttmErr) assert.Equal(t, "not-a-domain", ttmErr.Supplied) assert.Equal(t, "domain,url", ttmErr.ExpectedType) assert.Equal(t, "unknown", ttmErr.DetectedType) } 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 inline command/function execution in decision cases func TestExecutor_Decision_InlineCommand(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-inline-cmd", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "detected_type": "domain", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Command: "echo 'inline domain'"}, "ip": {Command: "echo 'inline ip'"}, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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) // Should execute: check-type -> inline command -> final-step assert.GreaterOrEqual(t, len(result.Steps), 3) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "decision-inline-bash", result.Steps[1].StepName) assert.Equal(t, "final-step", result.Steps[2].StepName) } func TestExecutor_Decision_InlineCommands(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-inline-cmds", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "detected_type": "domain", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Commands: []string{"echo 'cmd1'", "echo 'cmd2'"}}, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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) // Should execute: check-type -> 2 inline commands -> final-step assert.GreaterOrEqual(t, len(result.Steps), 4) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "decision-inline-bash", result.Steps[1].StepName) assert.Equal(t, "decision-inline-bash", result.Steps[2].StepName) assert.Equal(t, "final-step", result.Steps[3].StepName) } func TestExecutor_Decision_InlineFunction(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-inline-func", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "detected_type": "domain", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Function: "log_info('inline function executed')"}, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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) // Should execute: check-type -> inline function -> final-step assert.GreaterOrEqual(t, len(result.Steps), 3) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "decision-inline-function", result.Steps[1].StepName) assert.Equal(t, "final-step", result.Steps[2].StepName) } func TestExecutor_Decision_InlineFunctions(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-inline-funcs", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "detected_type": "domain", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Functions: []string{"log_info('func1')", "log_info('func2')"}}, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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) // Should execute: check-type -> inline functions step -> final-step assert.GreaterOrEqual(t, len(result.Steps), 3) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "decision-inline-function", result.Steps[1].StepName) assert.Equal(t, "final-step", result.Steps[2].StepName) } func TestExecutor_Decision_InlineWithGoto(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-inline-goto", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "detected_type": "domain", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": { Command: "echo 'inline before jump'", Goto: "target-step", }, }, }, }, { Name: "skipped-step", Type: core.StepTypeBash, Command: "echo 'should be skipped'", }, { Name: "target-step", Type: core.StepTypeBash, Command: "echo 'jumped here'", }, }, } 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) // Should execute: check-type -> inline command -> jump to target-step (skip skipped-step) assert.GreaterOrEqual(t, len(result.Steps), 3) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "decision-inline-bash", result.Steps[1].StepName) assert.Equal(t, "target-step", result.Steps[2].StepName) } func TestExecutor_Decision_DefaultInline(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-default-inline", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-type", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "detected_type": "unknown", }, Decision: &core.DecisionConfig{ Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Command: "echo 'domain'"}, }, Default: &core.DecisionCase{Command: "echo 'default executed'"}, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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 execute default inline command, then continue to final-step assert.GreaterOrEqual(t, len(result.Steps), 3) assert.Equal(t, "check-type", result.Steps[0].StepName) assert.Equal(t, "decision-inline-bash", result.Steps[1].StepName) assert.Equal(t, "final-step", result.Steps[2].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.RunUUID) 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) } func TestExecutionContext_CloneForLoop_PathFriendlyVariable(t *testing.T) { ctx := core.NewExecutionContext("test", core.KindModule, "run-1", "test.com") // Simple value: unsafe chars replaced with underscores clone := ctx.CloneForLoop("url", "https://example.com/path", 1) pathVar, ok := clone.GetVariable("_url_") assert.True(t, ok) sanitized, _ := pathVar.(string) assert.Equal(t, "https___example.com_path", sanitized) assert.NotContains(t, sanitized, "/") assert.NotContains(t, sanitized, ":") } func TestExecutionContext_CloneForLoop_PathFriendlyLongValue(t *testing.T) { ctx := core.NewExecutionContext("test", core.KindModule, "run-1", "test.com") // Long value: deterministic truncation with CRC32 hash longURL := "https://very-long-example-domain.com/with/a/very/deep/path/structure" clone1 := ctx.CloneForLoop("url", longURL, 1) clone2 := ctx.CloneForLoop("url", longURL, 2) v1, ok := clone1.GetVariable("_url_") assert.True(t, ok) s1 := v1.(string) v2, ok := clone2.GetVariable("_url_") assert.True(t, ok) s2 := v2.(string) // Same input produces same output (deterministic) assert.Equal(t, s1, s2) // Truncated: 20 chars prefix + "_" + 8 hex chars = 29 chars assert.LessOrEqual(t, len(s1), 29) } func TestExecutionContext_CloneForLoop_PathFriendlyEmptyLoopVar(t *testing.T) { ctx := core.NewExecutionContext("test", core.KindModule, "run-1", "test.com") // Empty loopVar: no __ variable should be created clone := ctx.CloneForLoop("", "some-value", 1) _, ok := clone.GetVariable("__") assert.False(t, ok, "Should not create __ variable when loopVar is empty") } func TestExecutionContext_CloneForLoop_PathFriendlyNonString(t *testing.T) { ctx := core.NewExecutionContext("test", core.KindModule, "run-1", "test.com") // Non-string loopValue: _count_ should not be created clone := ctx.CloneForLoop("count", 42, 1) // The raw loop variable should still be set rawVar, ok := clone.GetVariable("count") assert.True(t, ok) assert.Equal(t, 42, rawVar) // But the path-friendly variable should not exist _, ok = clone.GetVariable("_count_") assert.False(t, ok, "Should not create path-friendly variable for non-string values") } // 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) } } func TestExecutor_SkipModule(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-skip", Kind: core.KindModule, Steps: []core.Step{ { Name: "step-before", Type: core.StepTypeFunction, Function: "log_info('before skip')", }, { Name: "step-skip", Type: core.StepTypeFunction, Function: "skip('target not applicable')", }, { Name: "step-after", Type: core.StepTypeFunction, Function: "log_info('after skip')", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "test", }, cfg) // skip() returns nil error (not a failure) require.NoError(t, err) assert.Equal(t, core.RunStatusSkipped, result.Status) assert.Equal(t, "target not applicable", result.Message) // step-before should have executed, step-after should NOT assert.GreaterOrEqual(t, len(result.Steps), 2, "should have at least step-before and step-skip") // Find step-after in results - it should not be present for _, s := range result.Steps { assert.NotEqual(t, "step-after", s.StepName, "step-after should not have executed") } } func TestExecutor_SkipModulePreservesExports(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-skip-exports", Kind: core.KindModule, Steps: []core.Step{ { Name: "set-var", Type: core.StepTypeFunction, Function: "set_var('my_key', 'my_value')", }, { Name: "do-skip", Type: core.StepTypeFunction, Function: "skip('done early')", }, }, } 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.RunStatusSkipped, result.Status) assert.Equal(t, "done early", result.Message) } func TestIsFuzzyModuleExcluded(t *testing.T) { tests := []struct { name string moduleName string fuzzyList []string expected bool }{ {"exact substring match", "recon-spider", []string{"spider"}, true}, {"prefix match", "spider-crawl", []string{"spider"}, true}, {"no match", "recon-dns", []string{"spider"}, false}, {"empty list", "recon-spider", nil, false}, {"empty pattern list", "recon-spider", []string{}, false}, {"multiple patterns first matches", "recon-spider", []string{"spider", "dns"}, true}, {"multiple patterns second matches", "recon-dns", []string{"spider", "dns"}, true}, {"multiple patterns none match", "recon-http", []string{"spider", "dns"}, false}, {"full name as pattern", "recon-spider", []string{"recon-spider"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isFuzzyModuleExcluded(tt.moduleName, tt.fuzzyList) assert.Equal(t, tt.expected, result) }) } } func TestExecutor_FuzzyExcludeModules(t *testing.T) { ctx := context.Background() cfg := testConfig(t) // Create a flow where all modules will be excluded by fuzzy match flow := &core.Workflow{ Name: "test-fuzzy-exclude", Kind: core.KindFlow, Modules: []core.ModuleRef{ {Name: "recon-spider", Path: ""}, {Name: "spider-crawl", Path: ""}, }, } loader := parser.NewLoader(cfg.WorkflowsPath) exec := NewExecutor() exec.SetDryRun(true) exec.SetSpinner(false) exec.SetLoader(loader) // fuzzy_exclude_modules=spider should skip both recon-spider and spider-crawl result, err := exec.ExecuteFlow(ctx, flow, map[string]string{ "target": "test.example.com", "fuzzy_exclude_modules": "spider", }, cfg) require.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, core.RunStatusCompleted, result.Status) } // --- Decision Conditions tests --- func TestExecutor_Decision_ConditionMatch(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-cond-match", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-step", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "has_data": "true", }, Decision: &core.DecisionConfig{ Conditions: []core.DecisionCondition{ { If: "{{has_data}}", Function: "log_info('condition matched')", }, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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) // Should execute: check-step -> inline condition function -> final-step assert.GreaterOrEqual(t, len(result.Steps), 3) assert.Equal(t, "check-step", result.Steps[0].StepName) assert.Equal(t, "decision-condition-function", result.Steps[1].StepName) assert.Equal(t, "final-step", result.Steps[2].StepName) } func TestExecutor_Decision_ConditionNoMatch(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-cond-nomatch", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-step", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "has_data": "false", }, Decision: &core.DecisionConfig{ Conditions: []core.DecisionCondition{ { If: "{{has_data}}", Function: "log_info('should not run')", }, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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 condition matched, should only have: check-step -> final-step assert.Equal(t, 2, len(result.Steps)) assert.Equal(t, "check-step", result.Steps[0].StepName) assert.Equal(t, "final-step", result.Steps[1].StepName) } func TestExecutor_Decision_ConditionMultiple(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-cond-multi", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-step", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "flag_a": "true", "flag_b": "true", "flag_c": "false", }, Decision: &core.DecisionConfig{ Conditions: []core.DecisionCondition{ { If: "{{flag_a}}", Function: "log_info('condition A matched')", }, { If: "{{flag_b}}", Function: "log_info('condition B matched')", }, { If: "{{flag_c}}", Function: "log_info('condition C should not run')", }, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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) // Should execute: check-step -> cond A function -> cond B function -> final-step assert.GreaterOrEqual(t, len(result.Steps), 4) assert.Equal(t, "check-step", result.Steps[0].StepName) assert.Equal(t, "decision-condition-function", result.Steps[1].StepName) assert.Equal(t, "decision-condition-function", result.Steps[2].StepName) assert.Equal(t, "final-step", result.Steps[3].StepName) } func TestExecutor_Decision_ConditionWithGoto(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-cond-goto", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-step", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "should_skip": "true", }, Decision: &core.DecisionConfig{ Conditions: []core.DecisionCondition{ { If: "{{should_skip}}", Goto: "target-step", }, }, }, }, { Name: "skipped-step", Type: core.StepTypeBash, Command: "echo 'should be skipped'", }, { Name: "target-step", Type: core.StepTypeBash, Command: "echo 'jumped here'", }, }, } 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) // Should execute: check-step -> target-step (skipped-step skipped via goto) assert.Equal(t, 2, len(result.Steps)) assert.Equal(t, "check-step", result.Steps[0].StepName) assert.Equal(t, "target-step", result.Steps[1].StepName) } func TestExecutor_Decision_ConditionWithCommand(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-cond-cmd", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-step", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "run_extra": "true", }, Decision: &core.DecisionConfig{ Conditions: []core.DecisionCondition{ { If: "{{run_extra}}", Command: "echo 'condition command executed'", }, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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) // Should execute: check-step -> inline condition bash -> final-step assert.GreaterOrEqual(t, len(result.Steps), 3) assert.Equal(t, "check-step", result.Steps[0].StepName) assert.Equal(t, "decision-condition-bash", result.Steps[1].StepName) assert.Equal(t, "final-step", result.Steps[2].StepName) } func TestExecutor_Decision_ConditionWithSwitchCase(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-cond-switch", Kind: core.KindModule, Steps: []core.Step{ { Name: "check-step", Type: core.StepTypeBash, Command: "echo 'check'", Exports: map[string]string{ "detected_type": "domain", "extra_flag": "true", }, Decision: &core.DecisionConfig{ // Switch/cases for exact matching Switch: "{{detected_type}}", Cases: map[string]core.DecisionCase{ "domain": {Function: "log_info('switch matched domain')"}, }, // Conditions for boolean logic (both should execute) Conditions: []core.DecisionCondition{ { If: "{{extra_flag}}", Function: "log_info('condition also matched')", }, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } 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) // Should execute: check-step -> switch inline function -> condition inline function -> final-step assert.GreaterOrEqual(t, len(result.Steps), 4) assert.Equal(t, "check-step", result.Steps[0].StepName) assert.Equal(t, "decision-inline-function", result.Steps[1].StepName) assert.Equal(t, "decision-condition-function", result.Steps[2].StepName) assert.Equal(t, "final-step", result.Steps[3].StepName) } func TestStripTemplateVarsForJS(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "bare variables outside quotes", input: "{{enable_extra}} && {{target}} != ''", expected: "enable_extra && target != ''", }, { name: "variable inside single quotes preserved", input: "file_exists('{{output}}/results.txt')", expected: "file_exists('{{output}}/results.txt')", }, { name: "variable inside double quotes preserved", input: `file_exists("{{output}}/results.txt")`, expected: `file_exists("{{output}}/results.txt")`, }, { name: "mixed inside and outside quotes", input: "{{flag}} && file_exists('{{path}}/f.txt')", expected: "flag && file_exists('{{path}}/f.txt')", }, { name: "no template variables", input: "enable_extra && target != ''", expected: "enable_extra && target != ''", }, { name: "simple boolean variable", input: "{{flag_a}}", expected: "flag_a", }, { name: "numeric comparison", input: "{{scan_depth}} > 2", expected: "scan_depth > 2", }, { name: "variable with spaces in braces", input: "{{ flag_a }} && {{ target }}", expected: "flag_a && target", }, { name: "empty string", input: "", expected: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := stripTemplateVarsForJS(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestExecutor_Decision_ConditionWithParams(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-cond-params", Kind: core.KindModule, Params: []core.Param{ {Name: "target", Required: true}, {Name: "enable_extra", Type: "string", Default: "true"}, }, Steps: []core.Step{ { Name: "check-step", Type: core.StepTypeBash, Command: "echo 'check'", // No exports — condition uses params directly Decision: &core.DecisionConfig{ Conditions: []core.DecisionCondition{ { If: "{{enable_extra}} && {{target}} != ''", Function: "log_info('param condition matched')", }, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } executor := NewExecutor() executor.SetDryRun(false) 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) // Should execute: check-step -> inline condition function -> final-step assert.GreaterOrEqual(t, len(result.Steps), 3) assert.Equal(t, "check-step", result.Steps[0].StepName) assert.Equal(t, "decision-condition-function", result.Steps[1].StepName) assert.Equal(t, "final-step", result.Steps[2].StepName) } func TestExecutor_Decision_ConditionWithParams_Disabled(t *testing.T) { ctx := context.Background() cfg := testConfig(t) module := &core.Workflow{ Name: "test-decision-cond-params-disabled", Kind: core.KindModule, Params: []core.Param{ {Name: "target", Required: true}, {Name: "enable_extra", Type: "string", Default: "true"}, }, Steps: []core.Step{ { Name: "check-step", Type: core.StepTypeBash, Command: "echo 'check'", Decision: &core.DecisionConfig{ Conditions: []core.DecisionCondition{ { If: "{{enable_extra}} && {{target}} != ''", Function: "log_info('should not run')", }, }, }, }, { Name: "final-step", Type: core.StepTypeBash, Command: "echo 'done'", }, }, } executor := NewExecutor() executor.SetDryRun(false) executor.SetSpinner(false) // Pass enable_extra=false to disable the condition result, err := executor.ExecuteModule(ctx, module, map[string]string{ "target": "example.com", "enable_extra": "false", }, cfg) require.NoError(t, err) assert.Equal(t, core.RunStatusCompleted, result.Status) // Should execute: check-step -> final-step (condition skipped) assert.Equal(t, 2, len(result.Steps)) assert.Equal(t, "check-step", result.Steps[0].StepName) assert.Equal(t, "final-step", result.Steps[1].StepName) }