mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-17 21:25:49 +02:00
- 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
2576 lines
66 KiB
Go
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)
|
|
}
|