Files
osmedeus/internal/executor/executor_test.go
T
j3ssie 0da63bf9b3 feat: improve decision conditions with param-based JS evaluation
- Add stripTemplateVarsForJS() to convert {{var}} outside quotes to bare JS variable names, enabling direct param/export access in condition expressions
- Add normalizeBoolStringsForJS() to convert string 'true'/'false' to actual booleans for correct JS truthiness evaluation
- Auto-classify asset types in db_import_asset_from_file() and default source to 'web' when empty
- Improve table display with terminal auto-width detection via term.GetSize(), custom column weights, and display name aliases (status_code → status)
- Change default --width to 0 (auto-detect) with weighted column distribution and minimum header-based sizing
- Add comprehensive tests for param-based conditions and JSON asset imports
- Update test data to use direct param references in conditions instead of exports
2026-02-17 22:05:18 +07:00

2576 lines
66 KiB
Go

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)
}