From fba43ba3b575f2ab8a4d3a59ce56eec84e4ce95a Mon Sep 17 00:00:00 2001 From: j3ssie Date: Tue, 17 Feb 2026 17:00:43 +0700 Subject: [PATCH] feat: add string utility functions and decision condition tests - Add cut_to_file() and cut_space() utility functions for file processing and field extraction - Add comprehensive E2E tests for decision condition routing with function/command execution - Add test workflows for decision conditions and inline decision execution - Add short-mode skip guards to all cloud E2E tests to allow quick test runs - Register new functions in constants and goja runtime --- internal/core/step.go | 49 +- internal/core/types.go | 21 +- internal/executor/dispatcher.go | 10 - internal/executor/executor.go | 164 ++++- internal/executor/executor_test.go | 589 ++++++++++++++++++ internal/functions/constants.go | 6 + internal/functions/file_functions.go | 60 ++ internal/functions/goja_runtime.go | 2 + internal/functions/string_functions.go | 22 + internal/functions/util_functions_test.go | 139 +++++ test/e2e/cloud_test.go | 68 ++ test/e2e/decision_conditions_test.go | 150 +++++ test/e2e/decision_inline_test.go | 215 +++++++ .../workflows/test-decision-conditions.yaml | 76 +++ .../workflows/test-decision-inline.yaml | 102 +++ 15 files changed, 1639 insertions(+), 34 deletions(-) create mode 100644 test/e2e/decision_conditions_test.go create mode 100644 test/e2e/decision_inline_test.go create mode 100644 test/testdata/workflows/test-decision-conditions.yaml create mode 100644 test/testdata/workflows/test-decision-inline.yaml diff --git a/internal/core/step.go b/internal/core/step.go index 531f89d..20090c5 100644 --- a/internal/core/step.go +++ b/internal/core/step.go @@ -275,10 +275,37 @@ type Step struct { // DecisionCase represents a single case in switch-style decision type DecisionCase struct { - Goto string `yaml:"goto"` + Goto string `yaml:"goto,omitempty"` + Command string `yaml:"command,omitempty"` + Commands []string `yaml:"commands,omitempty"` + Function string `yaml:"function,omitempty"` + Functions []string `yaml:"functions,omitempty"` } -// DecisionConfig supports switch/case routing for conditional workflow branching. +// HasInlineExecution returns true if the case has inline command or function execution +func (dc *DecisionCase) HasInlineExecution() bool { + return dc.Command != "" || len(dc.Commands) > 0 || dc.Function != "" || len(dc.Functions) > 0 +} + +// DecisionCondition represents a condition-based decision entry evaluated via JS expressions. +// Unlike switch/cases (exact string matching), conditions support boolean logic. +// All matching conditions execute (no short-circuit). +type DecisionCondition struct { + If string `yaml:"if"` + Goto string `yaml:"goto,omitempty"` + Command string `yaml:"command,omitempty"` + Commands []string `yaml:"commands,omitempty"` + Function string `yaml:"function,omitempty"` + Functions []string `yaml:"functions,omitempty"` +} + +// HasInlineExecution returns true if the condition has inline command or function execution +func (dc *DecisionCondition) HasInlineExecution() bool { + return dc.Command != "" || len(dc.Commands) > 0 || dc.Function != "" || len(dc.Functions) > 0 +} + +// DecisionConfig supports switch/case routing for conditional workflow branching, +// and condition-based routing with JS boolean expressions. // // Switch/case syntax: // @@ -289,10 +316,20 @@ type DecisionCase struct { // "value2": { goto: step-b } // default: // goto: fallback-step +// +// Conditions syntax: +// +// decision: +// conditions: +// - if: "file_length('{{inputFile}}')" +// function: "log_info('file has content')" +// - if: "{{enableNmap}} && contains('{{Port}}', '-')" +// function: "log_info('long scan mode detected')" type DecisionConfig struct { - Switch string `yaml:"switch,omitempty"` - Cases map[string]DecisionCase `yaml:"cases,omitempty"` - Default *DecisionCase `yaml:"default,omitempty"` + Switch string `yaml:"switch,omitempty"` + Cases map[string]DecisionCase `yaml:"cases,omitempty"` + Default *DecisionCase `yaml:"default,omitempty"` + Conditions []DecisionCondition `yaml:"conditions,omitempty"` } // Action represents on_success/on_error handler @@ -372,7 +409,7 @@ func (s *Step) HasDecision() bool { if s.Decision == nil { return false } - return s.Decision.Switch != "" || len(s.Decision.Cases) > 0 + return s.Decision.Switch != "" || len(s.Decision.Cases) > 0 || len(s.Decision.Conditions) > 0 } // HasExports returns true if step exports variables diff --git a/internal/core/types.go b/internal/core/types.go index 43b497f..c311bdc 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -116,16 +116,17 @@ const ( // StepResult holds step execution result type StepResult struct { - StepName string - Status StepStatus - Output string - Error error - StartTime time.Time - EndTime time.Time - Duration time.Duration - Exports map[string]interface{} - NextStep string // from decision routing - LogFile string + StepName string + Status StepStatus + Output string + Error error + StartTime time.Time + EndTime time.Time + Duration time.Duration + Exports map[string]interface{} + NextStep string // from decision routing + LogFile string + InlineResults []*StepResult // results from inline decision case execution } // ModuleResult holds per-module execution result for flow workflows diff --git a/internal/executor/dispatcher.go b/internal/executor/dispatcher.go index bd7c3c8..0ea3a74 100644 --- a/internal/executor/dispatcher.go +++ b/internal/executor/dispatcher.go @@ -158,16 +158,6 @@ func (d *StepDispatcher) Dispatch(ctx context.Context, step *core.Step, execCtx zap.String("command", renderedStep.Command), ) - // Log step message if provided - if renderedStep.Log != "" { - if d.printer != nil { - d.printer.Info("%s", renderedStep.Log) - } - log.Debug(renderedStep.Log, - zap.String("step", step.Name), - ) - } - // Dispatch based on step type using plugin registry log.Debug("Dispatching to executor", zap.String("executor_type", string(step.Type)), diff --git a/internal/executor/executor.go b/internal/executor/executor.go index d27193e..a0c1aa3 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -1275,6 +1275,8 @@ func (e *Executor) ExecuteModule(ctx context.Context, module *core.Workflow, par stepResult, err := e.executeStep(ctx, step, execCtx) result.Steps = append(result.Steps, stepResult) + // Append inline decision case results (if any) + result.Steps = append(result.Steps, stepResult.InlineResults...) // Update progress bar with completed step if e.progressBar != nil { @@ -2432,6 +2434,14 @@ func (e *Executor) executeStep(ctx context.Context, step *core.Step, execCtx *co return result, nil } + // Print log message before step start if provided + if step.Log != "" { + renderedLog, logErr := e.templateEngine.Render(step.Log, execCtx.GetVariables()) + if logErr == nil && renderedLog != "" && e.progressBar == nil { + _, _ = fmt.Fprintf(os.Stdout, "%s %s\n", terminal.Cyan(terminal.SymbolAsterisk), renderedLog) + } + } + // Get step type symbol, command prefix, and command for display stepSymbol := terminal.StepTypeSymbol(string(step.Type), string(step.StepRunner)) cmdPrefix := terminal.StepCommandPrefix(string(step.Type)) @@ -2560,7 +2570,21 @@ func (e *Executor) executeStep(ctx context.Context, step *core.Step, execCtx *co // Evaluate decision routing if step.HasDecision() { - result.NextStep = e.evaluateDecision(step.Decision, execCtx) + // Switch/cases: exact string matching + if dc := e.evaluateDecision(step.Decision, execCtx); dc != nil { + if dc.HasInlineExecution() { + result.InlineResults = e.executeDecisionCase(ctx, dc, execCtx) + } + result.NextStep = dc.Goto + } + // Conditions: JS boolean expressions (all matching conditions execute) + if len(step.Decision.Conditions) > 0 { + gotoStep, condResults := e.evaluateConditions(ctx, step.Decision.Conditions, execCtx) + result.InlineResults = append(result.InlineResults, condResults...) + if gotoStep != "" { + result.NextStep = gotoStep + } + } } // Log step execution details to state execution log file @@ -2803,10 +2827,10 @@ func validateStepDependencies(steps []core.Step) error { return nil } -// evaluateDecision evaluates decision routing and returns the next step. -func (e *Executor) evaluateDecision(decision *core.DecisionConfig, execCtx *core.ExecutionContext) string { +// evaluateDecision evaluates decision routing and returns the matched case. +func (e *Executor) evaluateDecision(decision *core.DecisionConfig, execCtx *core.ExecutionContext) *core.DecisionCase { if decision == nil { - return "" + return nil } vars := execCtx.GetVariables() @@ -2816,22 +2840,146 @@ func (e *Executor) evaluateDecision(decision *core.DecisionConfig, execCtx *core // Render the switch expression switchValue, err := e.templateEngine.Render(decision.Switch, vars) if err != nil { - return "" + return nil } switchValue = strings.TrimSpace(switchValue) // Look up the case if caseAction, ok := decision.Cases[switchValue]; ok { - return caseAction.Goto + return &caseAction } // Fall through to default if decision.Default != nil { - return decision.Default.Goto + return decision.Default } } - return "" + return nil +} + +// executeDecisionCase executes inline commands/functions from a matched DecisionCase. +func (e *Executor) executeDecisionCase(ctx context.Context, dc *core.DecisionCase, execCtx *core.ExecutionContext) []*core.StepResult { + var results []*core.StepResult + + // Build command list from Command/Commands fields + commands := dc.Commands + if dc.Command != "" { + commands = append([]string{dc.Command}, commands...) + } + + // Execute bash commands via synthetic step + for _, cmd := range commands { + step := &core.Step{ + Name: "decision-inline-bash", + Type: core.StepTypeBash, + Command: cmd, + } + if sr, err := e.stepDispatcher.Dispatch(ctx, step, execCtx); err == nil && sr != nil { + results = append(results, sr) + } + } + + // Build function list from Function/Functions fields + functions := dc.Functions + if dc.Function != "" { + functions = append([]string{dc.Function}, functions...) + } + + // Execute functions via synthetic step + if len(functions) > 0 { + step := &core.Step{ + Name: "decision-inline-function", + Type: core.StepTypeFunction, + Functions: functions, + } + if sr, err := e.stepDispatcher.Dispatch(ctx, step, execCtx); err == nil && sr != nil { + results = append(results, sr) + } + } + + return results +} + +// evaluateConditions evaluates condition-based decision entries. +// All matching conditions execute their inline commands/functions (no short-circuit). +// Returns the last matched goto target (if any) and collected inline results. +func (e *Executor) evaluateConditions(ctx context.Context, conditions []core.DecisionCondition, execCtx *core.ExecutionContext) (string, []*core.StepResult) { + vars := execCtx.GetVariables() + var lastGoto string + var allResults []*core.StepResult + + for i := range conditions { + cond := &conditions[i] + + // Render template variables in the if expression + rendered, err := e.templateEngine.Render(cond.If, vars) + if err != nil { + continue + } + + // Evaluate the rendered expression as a JS boolean + ok, err := e.functionRegistry.EvaluateCondition(rendered, vars) + if err != nil || !ok { + continue + } + + // Condition matched — execute inline commands/functions + if cond.HasInlineExecution() { + results := e.executeDecisionCondition(ctx, cond, execCtx) + allResults = append(allResults, results...) + } + + // Save goto (last match wins) + if cond.Goto != "" { + lastGoto = cond.Goto + } + } + + return lastGoto, allResults +} + +// executeDecisionCondition executes inline commands/functions from a matched DecisionCondition. +func (e *Executor) executeDecisionCondition(ctx context.Context, dc *core.DecisionCondition, execCtx *core.ExecutionContext) []*core.StepResult { + var results []*core.StepResult + + // Build command list from Command/Commands fields + commands := dc.Commands + if dc.Command != "" { + commands = append([]string{dc.Command}, commands...) + } + + // Execute bash commands via synthetic step + for _, cmd := range commands { + step := &core.Step{ + Name: "decision-condition-bash", + Type: core.StepTypeBash, + Command: cmd, + } + if sr, err := e.stepDispatcher.Dispatch(ctx, step, execCtx); err == nil && sr != nil { + results = append(results, sr) + } + } + + // Build function list from Function/Functions fields + funcs := dc.Functions + if dc.Function != "" { + funcs = append([]string{dc.Function}, funcs...) + } + + // Execute functions via synthetic step + if len(funcs) > 0 { + step := &core.Step{ + Name: "decision-condition-function", + Type: core.StepTypeFunction, + Functions: funcs, + } + if sr, err := e.stepDispatcher.Dispatch(ctx, step, execCtx); err == nil && sr != nil { + results = append(results, sr) + } + } + + return results } // handleModuleAction handles a module action (for flow execution) diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index ec3a52b..1f76bba 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -1066,6 +1066,289 @@ func TestExecutor_Decision_SwitchCase_NoMatchNoDefault(t *testing.T) { 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) { @@ -1821,3 +2104,309 @@ func TestExecutor_FuzzyExcludeModules(t *testing.T) { 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) +} diff --git a/internal/functions/constants.go b/internal/functions/constants.go index 89d4b58..449af51 100644 --- a/internal/functions/constants.go +++ b/internal/functions/constants.go @@ -51,6 +51,7 @@ const ( FnGrepRegex = "grep_regex" // grep_regex(source, pattern) -> string FnRemoveBlankLines = "remove_blank_lines" // remove_blank_lines(path) -> bool (in-place) FnChunkFile = "chunk_file" // chunk_file(input, lines_per_chunk, output) -> bool + FnCutToFile = "cut_to_file" // cut_to_file(input_file, delim, field, output_file) -> bool ) // String Functions - String manipulation operations @@ -71,6 +72,7 @@ const ( FnRegexMatch = "regex_match" // regex_match(pattern, str) -> bool (pattern first) FnCutWithDelim = "cut_with_delim" // cut_with_delim(input, delim, field) -> string (1-indexed like cut) FnCut = "cut" // cut(input, delim, field) -> string (alias for cut_with_delim) + FnCutSpace = "cut_space" // cut_space(input, field) -> string (split by whitespace, 1-indexed) FnNormalizePath = "normalize_path" // normalize_path(input) -> string (replace / | : etc with _) FnGetTargetSpace = "get_target_space" // get_target_space(input) -> string (same as {{TargetSpace}}: sanitize + truncate) FnCleanSub = "clean_sub" // clean_sub(path, target?) -> bool (clean and deduplicate subdomains in file) @@ -402,6 +404,7 @@ func AllFunctions() []string { FnGrepRegex, FnRemoveBlankLines, FnChunkFile, + FnCutToFile, // String Functions FnTrim, @@ -420,6 +423,7 @@ func AllFunctions() []string { FnRegexMatch, FnCutWithDelim, FnCut, + FnCutSpace, FnNormalizePath, FnGetTargetSpace, FnCleanSub, @@ -780,6 +784,7 @@ func FunctionRegistry() map[string][]FunctionInfo { {FnGrepRegex, "grep_regex(source, pattern)", "Return lines matching regex", "string", "grep_regex('{{Output}}/in.txt', '.*api.*')"}, {FnRemoveBlankLines, "remove_blank_lines(path)", "Remove blank lines from file in-place", "bool", "remove_blank_lines('{{Output}}/urls.txt')"}, {FnChunkFile, "chunk_file(input, lines_per_chunk, output)", "Split file into chunks and write manifest of chunk paths", "bool", "chunk_file('{{Output}}/urls.txt', 100, '{{Output}}/url_chunks.txt')"}, + {FnCutToFile, "cut_to_file(input_file, delim, field, output_file)", "Extract field from each line by delimiter and write to output file", "bool", "cut_to_file('{{Output}}/urls.txt', '/', 3, '{{Output}}/domains.txt')"}, }, CategoryString: { {FnTrim, "trim(str)", "Trim whitespace", "string", "trim(' hello ')"}, @@ -798,6 +803,7 @@ func FunctionRegistry() map[string][]FunctionInfo { {FnRegexMatch, "regex_match(pattern, str)", "Check if string matches regex (pattern first)", "bool", "regex_match('[0-9]+', 'test123')"}, {FnCutWithDelim, "cut_with_delim(input, delim, field)", "Extract field by delimiter (1-indexed)", "string", "cut_with_delim('a:b:c', ':', 2)"}, {FnCut, "cut(input, delim, field)", "Extract field by delimiter (1-indexed, alias for cut_with_delim)", "string", "cut('a:b:c', ':', 2)"}, + {FnCutSpace, "cut_space(input, field)", "Extract field by whitespace (1-indexed, handles multiple spaces/tabs)", "string", "cut_space('hello world', 2)"}, {FnNormalizePath, "normalize_path(input)", "Replace special chars with underscore", "string", "normalize_path('test/path:file')"}, {FnGetTargetSpace, "get_target_space(input)", "Normalize to path-friendly format (same as {{TargetSpace}})", "string", "get_target_space('https://example.com/path')"}, {FnCleanSub, "clean_sub(path, target?)", "Clean and deduplicate subdomains in file, optionally filter by target domain", "bool", "clean_sub('{{Output}}/subdomains.txt', 'example.com')"}, diff --git a/internal/functions/file_functions.go b/internal/functions/file_functions.go index 3043edc..e0e05ca 100644 --- a/internal/functions/file_functions.go +++ b/internal/functions/file_functions.go @@ -564,6 +564,66 @@ func (vf *vmFunc) chunkFile(call goja.FunctionCall) goja.Value { return vf.vm.ToValue(true) } +// cutToFile reads a file line by line, extracts a field by delimiter, and writes results to output +// Usage: cut_to_file(input_file, delim, field, output_file) -> bool +func (vf *vmFunc) cutToFile(call goja.FunctionCall) goja.Value { + inputFile := call.Argument(0).String() + delim := call.Argument(1).String() + field := int(call.Argument(2).ToInteger()) + outputFile := call.Argument(3).String() + log := logger.Get() + + log.Debug("Calling "+terminal.HiGreen("cut_to_file"), + zap.String("input", inputFile), zap.String("delim", delim), + zap.Int("field", field), zap.String("output", outputFile)) + + if inputFile == "undefined" || inputFile == "" || outputFile == "undefined" || outputFile == "" { + log.Warn("cut_to_file: input and output paths are required") + return vf.vm.ToValue(false) + } + if delim == "undefined" || delim == "" { + log.Warn("cut_to_file: delimiter is required") + return vf.vm.ToValue(false) + } + + f, err := os.Open(inputFile) + if err != nil { + log.Warn("cut_to_file: failed to open input file", zap.String("input", inputFile), zap.Error(err)) + return vf.vm.ToValue(false) + } + defer func() { _ = f.Close() }() + + idx := field - 1 // Convert 1-indexed to 0-indexed + var results []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + parts := strings.Split(line, delim) + if idx >= 0 && idx < len(parts) { + val := parts[idx] + if val != "" { + results = append(results, val) + } + } + } + if err := scanner.Err(); err != nil { + log.Warn("cut_to_file: failed to read input file", zap.String("input", inputFile), zap.Error(err)) + return vf.vm.ToValue(false) + } + + if err := writeLinesToFile(outputFile, results); err != nil { + log.Warn("cut_to_file: failed to write output file", zap.String("output", outputFile), zap.Error(err)) + return vf.vm.ToValue(false) + } + + log.Debug(terminal.HiGreen("cut_to_file")+" result", + zap.String("input", inputFile), zap.Int("lines", len(results)), zap.String("output", outputFile)) + return vf.vm.ToValue(true) +} + // zipDir creates a zip archive from a directory using Go's archive/zip // Usage: zip_dir(source, dest) -> bool func (vf *vmFunc) zipDir(call goja.FunctionCall) goja.Value { diff --git a/internal/functions/goja_runtime.go b/internal/functions/goja_runtime.go index 651fa0b..9035402 100644 --- a/internal/functions/goja_runtime.go +++ b/internal/functions/goja_runtime.go @@ -68,6 +68,7 @@ func (r *GojaRuntime) registerFunctionsOnVM(vm *goja.Runtime) { _ = vm.Set(FnGrepRegex, vf.grepRegex) _ = vm.Set(FnRemoveBlankLines, vf.removeBlankLines) _ = vm.Set(FnChunkFile, vf.chunkFile) + _ = vm.Set(FnCutToFile, vf.cutToFile) // String functions _ = vm.Set(FnTrim, vf.trim) @@ -86,6 +87,7 @@ func (r *GojaRuntime) registerFunctionsOnVM(vm *goja.Runtime) { _ = vm.Set(FnRegexMatch, vf.regexMatch) _ = vm.Set(FnCutWithDelim, vf.cutWithDelim) _ = vm.Set(FnCut, vf.cutWithDelim) // alias for cut_with_delim + _ = vm.Set(FnCutSpace, vf.cutSpace) _ = vm.Set(FnNormalizePath, vf.normalizePath) _ = vm.Set(FnGetTargetSpace, vf.getTargetSpace) _ = vm.Set(FnCleanSub, vf.cleanSub) diff --git a/internal/functions/string_functions.go b/internal/functions/string_functions.go index 98db464..6a33923 100644 --- a/internal/functions/string_functions.go +++ b/internal/functions/string_functions.go @@ -267,6 +267,28 @@ func (vf *vmFunc) cutWithDelim(call goja.FunctionCall) goja.Value { return vf.vm.ToValue(parts[idx]) } +// cutSpace extracts a field from input split by whitespace (1-indexed) +// Uses strings.Fields() which handles multiple spaces, tabs, and mixed whitespace +// Usage: cut_space(input, field) -> string +func (vf *vmFunc) cutSpace(call goja.FunctionCall) goja.Value { + input := call.Argument(0).String() + field := call.Argument(1).ToInteger() + + if input == "undefined" { + return vf.vm.ToValue("") + } + + parts := strings.Fields(input) + + // Field is 1-indexed (like cut command) + idx := int(field) - 1 + if idx < 0 || idx >= len(parts) { + return vf.vm.ToValue("") + } + + return vf.vm.ToValue(parts[idx]) +} + // normalizePath replaces special characters with underscore for clean directory/file names // Replaces: / | : \ * ? " < > with _ // Usage: normalize_path(input) -> string diff --git a/internal/functions/util_functions_test.go b/internal/functions/util_functions_test.go index 9325c68..b175b54 100644 --- a/internal/functions/util_functions_test.go +++ b/internal/functions/util_functions_test.go @@ -1007,6 +1007,145 @@ func TestSkip(t *testing.T) { }) } +func TestCutSpace(t *testing.T) { + runtime := NewOttoRuntime() + + t.Run("basic split", func(t *testing.T) { + result, err := runtime.Execute(`cut_space("hello world", 1)`, nil) + require.NoError(t, err) + assert.Equal(t, "hello", result) + }) + + t.Run("second field", func(t *testing.T) { + result, err := runtime.Execute(`cut_space("hello world", 2)`, nil) + require.NoError(t, err) + assert.Equal(t, "world", result) + }) + + t.Run("multiple spaces", func(t *testing.T) { + result, err := runtime.Execute(`cut_space("hello world", 2)`, nil) + require.NoError(t, err) + assert.Equal(t, "world", result) + }) + + t.Run("tabs", func(t *testing.T) { + result, err := runtime.Execute(`cut_space("hello\tworld", 2)`, nil) + require.NoError(t, err) + assert.Equal(t, "world", result) + }) + + t.Run("mixed whitespace", func(t *testing.T) { + result, err := runtime.Execute(`cut_space(" hello \t world ", 2)`, nil) + require.NoError(t, err) + assert.Equal(t, "world", result) + }) + + t.Run("out of range returns empty", func(t *testing.T) { + result, err := runtime.Execute(`cut_space("hello world", 5)`, nil) + require.NoError(t, err) + assert.Equal(t, "", result) + }) + + t.Run("field zero returns empty", func(t *testing.T) { + result, err := runtime.Execute(`cut_space("hello world", 0)`, nil) + require.NoError(t, err) + assert.Equal(t, "", result) + }) + + t.Run("empty input returns empty", func(t *testing.T) { + result, err := runtime.Execute(`cut_space("", 1)`, nil) + require.NoError(t, err) + assert.Equal(t, "", result) + }) + + t.Run("three fields", func(t *testing.T) { + result, err := runtime.Execute(`cut_space("one two three", 3)`, nil) + require.NoError(t, err) + assert.Equal(t, "three", result) + }) +} + +func TestCutToFile(t *testing.T) { + runtime := NewOttoRuntime() + + t.Run("basic file processing", func(t *testing.T) { + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "input.txt") + outputFile := filepath.Join(tmpDir, "output.txt") + + err := os.WriteFile(inputFile, []byte("a,b,c\nd,e,f\ng,h,i\n"), 0644) + require.NoError(t, err) + + result, err := runtime.Execute(`cut_to_file("`+inputFile+`", ",", 2, "`+outputFile+`")`, nil) + require.NoError(t, err) + assert.Equal(t, true, result) + + content, err := os.ReadFile(outputFile) + require.NoError(t, err) + assert.Equal(t, "b\ne\nh\n", string(content)) + }) + + t.Run("colon delimiter", func(t *testing.T) { + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "input.txt") + outputFile := filepath.Join(tmpDir, "output.txt") + + err := os.WriteFile(inputFile, []byte("root:x:0:0\nnobody:x:65534:65534\n"), 0644) + require.NoError(t, err) + + result, err := runtime.Execute(`cut_to_file("`+inputFile+`", ":", 1, "`+outputFile+`")`, nil) + require.NoError(t, err) + assert.Equal(t, true, result) + + content, err := os.ReadFile(outputFile) + require.NoError(t, err) + assert.Equal(t, "root\nnobody\n", string(content)) + }) + + t.Run("out of range field produces empty output", func(t *testing.T) { + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "input.txt") + outputFile := filepath.Join(tmpDir, "output.txt") + + err := os.WriteFile(inputFile, []byte("a,b\nc,d\n"), 0644) + require.NoError(t, err) + + result, err := runtime.Execute(`cut_to_file("`+inputFile+`", ",", 10, "`+outputFile+`")`, nil) + require.NoError(t, err) + assert.Equal(t, true, result) + + content, err := os.ReadFile(outputFile) + require.NoError(t, err) + assert.Equal(t, "", string(content)) + }) + + t.Run("skips blank lines", func(t *testing.T) { + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "input.txt") + outputFile := filepath.Join(tmpDir, "output.txt") + + err := os.WriteFile(inputFile, []byte("a,b\n\nc,d\n \ne,f\n"), 0644) + require.NoError(t, err) + + result, err := runtime.Execute(`cut_to_file("`+inputFile+`", ",", 2, "`+outputFile+`")`, nil) + require.NoError(t, err) + assert.Equal(t, true, result) + + content, err := os.ReadFile(outputFile) + require.NoError(t, err) + assert.Equal(t, "b\nd\nf\n", string(content)) + }) + + t.Run("nonexistent input returns false", func(t *testing.T) { + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "output.txt") + + result, err := runtime.Execute(`cut_to_file("/tmp/nonexistent_cut_test_12345.txt", ",", 1, "`+outputFile+`")`, nil) + require.NoError(t, err) + assert.Equal(t, false, result) + }) +} + func TestSkipModuleError(t *testing.T) { t.Run("Error returns formatted message", func(t *testing.T) { err := &SkipModuleError{Message: "test message"} diff --git a/test/e2e/cloud_test.go b/test/e2e/cloud_test.go index 9044fc1..fda73df 100644 --- a/test/e2e/cloud_test.go +++ b/test/e2e/cloud_test.go @@ -15,6 +15,10 @@ import ( // TestCloud_ConfigSet tests cloud configuration setting via CLI func TestCloud_ConfigSet(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud config set command") @@ -29,6 +33,10 @@ func TestCloud_ConfigSet(t *testing.T) { // TestCloud_ConfigShow tests cloud configuration display func TestCloud_ConfigShow(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud config show command") @@ -54,6 +62,10 @@ func TestCloud_ConfigShow(t *testing.T) { // TestCloud_ConfigSetInvalidKey tests error handling for invalid config keys func TestCloud_ConfigSetInvalidKey(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud config set with invalid key") @@ -73,6 +85,10 @@ func TestCloud_ConfigSetInvalidKey(t *testing.T) { // TestCloud_ConfigEnvironmentVariables tests environment variable resolution func TestCloud_ConfigEnvironmentVariables(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud config with environment variables") @@ -101,6 +117,10 @@ func TestCloud_ConfigEnvironmentVariables(t *testing.T) { // TestCloud_CreateHelp tests cloud create help output func TestCloud_CreateHelp(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud create --help") @@ -190,6 +210,10 @@ func TestCloud_CreateDryRun(t *testing.T) { // TestCloud_List tests cloud infrastructure listing func TestCloud_List(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud list command") @@ -212,6 +236,10 @@ func TestCloud_List(t *testing.T) { // TestCloud_DestroyHelp tests cloud destroy help output func TestCloud_DestroyHelp(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud destroy --help") @@ -227,6 +255,10 @@ func TestCloud_DestroyHelp(t *testing.T) { // TestCloud_DestroyNonExistent tests destroying non-existent infrastructure func TestCloud_DestroyNonExistent(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud destroy with non-existent ID") @@ -253,6 +285,10 @@ func TestCloud_DestroyNonExistent(t *testing.T) { // TestCloud_RunHelp tests cloud run help output func TestCloud_RunHelp(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud run --help") @@ -298,6 +334,10 @@ func TestCloud_RunWithoutTarget(t *testing.T) { // TestCloud_ConfigFileCreation tests that cloud config file is created properly func TestCloud_ConfigFileCreation(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud config file creation") @@ -331,6 +371,10 @@ func TestCloud_ConfigFileCreation(t *testing.T) { // TestCloud_MultipleProviderConfigs tests configuring multiple cloud providers func TestCloud_MultipleProviderConfigs(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing multiple cloud provider configurations") @@ -365,6 +409,10 @@ func TestCloud_MultipleProviderConfigs(t *testing.T) { // TestCloud_CostLimitConfiguration tests cost limit settings func TestCloud_CostLimitConfiguration(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cost limit configuration") @@ -395,6 +443,10 @@ func TestCloud_CostLimitConfiguration(t *testing.T) { // TestCloud_StateDirectory tests cloud state directory creation func TestCloud_StateDirectory(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing cloud state directory") @@ -471,6 +523,10 @@ func TestCloud_Integration_FullLifecycle(t *testing.T) { // TestCloud_SSHKeyConfiguration tests SSH key settings for cloud workers func TestCloud_SSHKeyConfiguration(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing SSH key configuration for cloud") @@ -774,6 +830,10 @@ func TestCloud_ParallelOperations(t *testing.T) { // TestCloud_SpotInstanceConfiguration tests spot/preemptible instance settings func TestCloud_SpotInstanceConfiguration(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing spot instance configuration") @@ -803,6 +863,10 @@ func TestCloud_SpotInstanceConfiguration(t *testing.T) { // TestCloud_CustomSetupCommands tests custom worker setup commands func TestCloud_CustomSetupCommands(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing custom setup commands configuration") @@ -829,6 +893,10 @@ func TestCloud_CustomSetupCommands(t *testing.T) { // TestCloud_ProviderRegions tests region configuration for different providers func TestCloud_ProviderRegions(t *testing.T) { + if testing.Short() { + t.Skip("skipping cloud E2E test in short mode") + } + log := NewTestLogger(t) log.Step("Testing provider region configuration") diff --git a/test/e2e/decision_conditions_test.go b/test/e2e/decision_conditions_test.go new file mode 100644 index 0000000..99cddb0 --- /dev/null +++ b/test/e2e/decision_conditions_test.go @@ -0,0 +1,150 @@ +package e2e + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDecisionConditions_SingleMatch tests a single condition that evaluates to true +func TestDecisionConditions_SingleMatch(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing single condition match") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-conditions", "-t", "example.com", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting workflow completed") + assert.Contains(t, stdout, "completed") + + log.Info("Asserting single condition function executed") + assert.Contains(t, stdout, "[COND] Target is present") + + log.Success("single condition match works correctly") +} + +// TestDecisionConditions_MultipleMatch tests that all matching conditions execute (no short-circuit) +func TestDecisionConditions_MultipleMatch(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing multiple conditions - all matching ones execute") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-conditions", "-t", "example.com", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting both matching conditions executed") + assert.Contains(t, stdout, "[COND-A] Flag A is true") + assert.Contains(t, stdout, "[COND-B] Flag B is true") + + log.Info("Asserting non-matching condition did not execute") + assert.NotContains(t, stdout, "[COND-C]") + + log.Success("multiple condition matching works correctly") +} + +// TestDecisionConditions_InlineCommand tests condition with inline bash command +func TestDecisionConditions_InlineCommand(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing condition with inline command") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-conditions", "-t", "example.com", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting inline command executed") + assert.Contains(t, stdout, "[COND-CMD] Extra command executed") + + log.Info("Asserting inline bash step appears in results") + assert.Contains(t, stdout, "decision-condition-bash") + + log.Success("condition inline command works correctly") +} + +// TestDecisionConditions_GotoSkipsStep tests condition with goto skips intermediate steps +func TestDecisionConditions_GotoSkipsStep(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing condition goto skips intermediate steps") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-conditions", "-t", "example.com", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting condition function ran before goto") + assert.Contains(t, stdout, "[COND-GOTO] Jumping to final") + + log.Info("Asserting skipped step was not executed") + assert.NotContains(t, stdout, "[SKIPPED]") + + log.Info("Asserting final step executed after goto") + assert.Contains(t, stdout, "[FINAL] Scan complete for example.com") + + // Verify ordering: goto function before final + idxGoto := strings.Index(stdout, "[COND-GOTO]") + idxFinal := strings.Index(stdout, "[FINAL]") + assert.True(t, idxGoto >= 0, "goto condition output not found") + assert.True(t, idxFinal >= 0, "final output not found") + assert.True(t, idxGoto < idxFinal, "goto condition should appear before final step") + + log.Success("condition goto correctly skips intermediate steps") +} + +// TestDecisionConditions_NoMatchDisabled tests that false conditions do not execute +func TestDecisionConditions_NoMatchDisabled(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing condition with enable_extra=false does not run command") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-conditions", "-t", "example.com", + "-p", "enable_extra=false", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting workflow completed") + assert.Contains(t, stdout, "completed") + + log.Info("Asserting inline command did NOT execute when condition is false") + assert.NotContains(t, stdout, "[COND-CMD] Extra command executed") + + log.Success("false condition correctly prevents execution") +} + +// TestDecisionConditions_DryRun tests that the workflow validates in dry-run mode +func TestDecisionConditions_DryRun(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing decision-conditions workflow in dry-run mode") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-conditions", "-t", "example.com", "--dry-run", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting dry-run mode") + assert.Contains(t, stdout, "DRY-RUN") + assert.Contains(t, stdout, "test-decision-conditions") + + log.Success("decision-conditions workflow dry-run works correctly") +} + +// TestDecisionConditions_WorkflowValidate tests that the workflow passes validation +func TestDecisionConditions_WorkflowValidate(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing decision-conditions workflow validation") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "workflow", "validate", "test-decision-conditions", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting workflow is valid") + assert.True(t, strings.Contains(stdout, "is valid") || strings.Contains(stdout, "passed all lint checks"), + "expected validation success message") + + log.Success("decision-conditions workflow passes validation") +} diff --git a/test/e2e/decision_inline_test.go b/test/e2e/decision_inline_test.go new file mode 100644 index 0000000..70d5718 --- /dev/null +++ b/test/e2e/decision_inline_test.go @@ -0,0 +1,215 @@ +package e2e + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDecisionInline_QuickMode tests inline execution in decision cases with default "quick" mode +func TestDecisionInline_QuickMode(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing decision inline execution with quick mode (default)") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-inline", "-t", "example.com", "-F", workflowPath) + require.NoError(t, err) + + // Verify workflow completed + log.Info("Asserting workflow completed") + assert.Contains(t, stdout, "completed") + + // Verify inline function executed for "quick" case (log_info output is visible) + log.Info("Asserting inline function ran for quick mode") + assert.Contains(t, stdout, "[INLINE] Quick scan selected") + + // Verify inline functions (plural) executed + log.Info("Asserting inline functions (plural) ran") + assert.Contains(t, stdout, "[MULTI-FUNC-1] Configuring quick scan") + assert.Contains(t, stdout, "[MULTI-FUNC-2] Quick scan configured") + + // Verify inline bash steps appear in step table + log.Info("Asserting inline bash steps appear in results") + assert.Contains(t, stdout, "decision-inline-bash") + assert.Contains(t, stdout, "decision-inline-function") + + // Verify inline function+goto worked (inline runs, then jumps) + log.Info("Asserting inline function with goto ran") + assert.Contains(t, stdout, "[INLINE-GOTO] Quick inline before jump") + + // Verify goto skipped the skipped-step + log.Info("Asserting skipped step was not executed") + assert.NotContains(t, stdout, "[SKIPPED]") + + // Verify final step ran + log.Info("Asserting final step executed") + assert.Contains(t, stdout, "[FINAL] Scan complete for example.com in quick mode") + + log.Success("decision inline execution works correctly for quick mode") +} + +// TestDecisionInline_DeepMode tests inline execution when a different case is matched +func TestDecisionInline_DeepMode(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing decision inline execution with deep mode") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-inline", "-t", "target.io", + "-p", "scan_mode=deep", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting workflow completed") + assert.Contains(t, stdout, "completed") + + // Verify deep mode inline function executed + log.Info("Asserting deep mode inline function ran") + assert.Contains(t, stdout, "[INLINE] Deep scan selected") + + // Verify deep mode multi-functions executed + log.Info("Asserting deep mode multi-functions ran") + assert.Contains(t, stdout, "[MULTI-FUNC-1] Configuring deep scan") + assert.Contains(t, stdout, "[MULTI-FUNC-2] Deep scan configured") + + // Verify quick mode was not executed + log.Info("Asserting quick mode functions did not run") + assert.NotContains(t, stdout, "Quick scan selected") + assert.NotContains(t, stdout, "Configuring quick scan") + + // Verify final step + log.Info("Asserting final step executed") + assert.Contains(t, stdout, "[FINAL] Scan complete for target.io in deep mode") + + log.Success("decision inline execution works correctly for deep mode") +} + +// TestDecisionInline_DefaultCase tests that the default case inline runs when no case matches +func TestDecisionInline_DefaultCase(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing decision inline with unmatched value (default case)") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-inline", "-t", "example.com", + "-p", "scan_mode=unknown", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting workflow completed") + assert.Contains(t, stdout, "completed") + + // Verify default case inline function executed + log.Info("Asserting default inline function ran") + assert.Contains(t, stdout, "[INLINE] Default mode selected") + + // Verify no quick or deep functions ran + log.Info("Asserting non-matching cases did not run") + assert.NotContains(t, stdout, "Quick scan selected") + assert.NotContains(t, stdout, "Deep scan selected") + + // Verify default goto worked in branch-and-jump step + log.Info("Asserting default goto jumped correctly") + assert.Contains(t, stdout, "[INLINE-GOTO] Default inline before jump") + assert.NotContains(t, stdout, "[SKIPPED]") + + log.Success("default case inline execution works correctly") +} + +// TestDecisionInline_GotoSkipsStep tests that goto after inline execution correctly skips intermediate steps +func TestDecisionInline_GotoSkipsStep(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing that inline+goto correctly skips intermediate steps") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-inline", "-t", "example.com", "-F", workflowPath) + require.NoError(t, err) + + // The inline function should run before goto + log.Info("Asserting inline function ran before goto") + assert.Contains(t, stdout, "[INLINE-GOTO] Quick inline before jump") + + // The skipped-step should not appear + log.Info("Asserting skipped-step was skipped by goto") + assert.NotContains(t, stdout, "[SKIPPED] This should not appear") + + // The final-step should appear after the inline goto + log.Info("Asserting final-step ran after goto") + assert.Contains(t, stdout, "[FINAL] Scan complete") + + // Verify ordering: inline-goto appears before final + idxInline := strings.Index(stdout, "[INLINE-GOTO]") + idxFinal := strings.Index(stdout, "[FINAL]") + assert.True(t, idxInline >= 0, "inline-goto output not found") + assert.True(t, idxFinal >= 0, "final output not found") + assert.True(t, idxInline < idxFinal, "inline-goto should appear before final step") + + log.Success("inline+goto correctly skips intermediate steps") +} + +// TestDecisionInline_StepTable tests that inline steps appear correctly in the step results table +func TestDecisionInline_StepTable(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing inline step entries in step results table") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-inline", "-t", "example.com", "-F", workflowPath) + require.NoError(t, err) + + // Verify step table contains inline step entries + log.Info("Asserting step table contains inline bash steps") + assert.Contains(t, stdout, "decision-inline-bash") + + log.Info("Asserting step table contains inline function steps") + assert.Contains(t, stdout, "decision-inline-function") + + // Verify the main workflow steps are present + log.Info("Asserting main steps are present") + assert.Contains(t, stdout, "detect-mode") + assert.Contains(t, stdout, "setup-scan") + assert.Contains(t, stdout, "branch-and-jump") + assert.Contains(t, stdout, "final-step") + + // Verify total completed steps > workflow step count (inline steps add extra) + log.Info("Asserting completed steps include inline steps") + assert.Contains(t, stdout, "completed_steps") + + log.Success("inline steps appear correctly in step results table") +} + +// TestDecisionInline_DryRun tests that the workflow validates in dry-run mode +func TestDecisionInline_DryRun(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing decision-inline workflow in dry-run mode") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "run", "-m", "test-decision-inline", "-t", "example.com", "--dry-run", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting dry-run mode") + assert.Contains(t, stdout, "DRY-RUN") + assert.Contains(t, stdout, "test-decision-inline") + + log.Success("decision-inline workflow dry-run works correctly") +} + +// TestDecisionInline_WorkflowValidate tests that the workflow passes validation +func TestDecisionInline_WorkflowValidate(t *testing.T) { + log := NewTestLogger(t) + log.Step("Testing decision-inline workflow validation") + + workflowPath := getTestdataPath(t) + + stdout, _, err := runCLIWithLog(t, log, "workflow", "validate", "test-decision-inline", "-F", workflowPath) + require.NoError(t, err) + + log.Info("Asserting workflow is valid") + assert.True(t, strings.Contains(stdout, "is valid") || strings.Contains(stdout, "passed all lint checks"), + "expected validation success message") + + log.Success("decision-inline workflow passes validation") +} diff --git a/test/testdata/workflows/test-decision-conditions.yaml b/test/testdata/workflows/test-decision-conditions.yaml new file mode 100644 index 0000000..e8aab25 --- /dev/null +++ b/test/testdata/workflows/test-decision-conditions.yaml @@ -0,0 +1,76 @@ +name: test-decision-conditions +kind: module +description: Test condition-based decision routing with JS boolean expressions +tags: test,decision,conditions + +params: + - name: target + required: true + - name: enable_extra + type: string + default: "true" + - name: scan_depth + type: string + default: "3" + +steps: + # Step 1: Single condition match with function + - name: check-target + type: bash + command: echo "Checking target" + exports: + has_target: "true" + decision: + conditions: + - if: "{{has_target}}" + function: "log_info('[COND] Target is present')" + + # Step 2: Multiple conditions - all matching ones execute + - name: check-flags + type: bash + command: echo "Checking flags" + exports: + flag_a: "true" + flag_b: "true" + flag_c: "false" + decision: + conditions: + - if: "{{flag_a}}" + function: "log_info('[COND-A] Flag A is true')" + - if: "{{flag_b}}" + function: "log_info('[COND-B] Flag B is true')" + - if: "{{flag_c}}" + function: "log_info('[COND-C] Flag C should not appear')" + + # Step 3: Condition with inline command + - name: check-extra + type: bash + command: echo "Checking extra" + exports: + run_extra: "{{enable_extra}}" + decision: + conditions: + - if: "{{run_extra}}" + command: echo "[COND-CMD] Extra command executed" + + # Step 4: Condition with goto + - name: check-skip + type: bash + command: echo "Checking skip condition" + exports: + should_skip: "true" + decision: + conditions: + - if: "{{should_skip}}" + function: "log_info('[COND-GOTO] Jumping to final')" + goto: final-step + + # This step should be skipped via goto + - name: skipped-step + type: bash + command: echo "[SKIPPED] This should not appear" + + # Step 5: Conditions coexisting with switch/cases + - name: final-step + type: bash + command: echo "[FINAL] Scan complete for {{target}}" diff --git a/test/testdata/workflows/test-decision-inline.yaml b/test/testdata/workflows/test-decision-inline.yaml new file mode 100644 index 0000000..e94263b --- /dev/null +++ b/test/testdata/workflows/test-decision-inline.yaml @@ -0,0 +1,102 @@ +name: test-decision-inline +kind: module +description: Test inline command/function execution in decision cases +tags: test,decision,inline + +params: + - name: target + required: true + - name: scan_mode + type: string + default: "quick" + +steps: + # Step 1: Test inline function for visible output + - name: detect-mode + type: bash + command: echo "Detecting scan mode" + exports: + mode: "{{scan_mode}}" + decision: + switch: "{{mode}}" + cases: + "quick": + function: "log_info('[INLINE] Quick scan selected')" + "deep": + function: "log_info('[INLINE] Deep scan selected')" + default: + function: "log_info('[INLINE] Default mode selected')" + + # Step 2: Test inline functions (plural) with multiple functions + - name: setup-scan + type: bash + command: echo "Setting up scan" + exports: + setup_mode: "{{scan_mode}}" + decision: + switch: "{{setup_mode}}" + cases: + "quick": + functions: + - "log_info('[MULTI-FUNC-1] Configuring quick scan')" + - "log_info('[MULTI-FUNC-2] Quick scan configured')" + "deep": + functions: + - "log_info('[MULTI-FUNC-1] Configuring deep scan')" + - "log_info('[MULTI-FUNC-2] Deep scan configured')" + + # Step 3: Test inline command execution (verifiable via step table) + - name: run-inline-cmd + type: bash + command: echo "Running inline command test" + exports: + cmd_mode: "{{scan_mode}}" + decision: + switch: "{{cmd_mode}}" + cases: + "quick": + command: echo "quick inline command executed" + "deep": + command: echo "deep inline command executed" + + # Step 4: Test inline commands (plural) - multiple bash commands + - name: run-multi-cmd + type: bash + command: echo "Running multi-command test" + exports: + multi_mode: "{{scan_mode}}" + decision: + switch: "{{multi_mode}}" + cases: + "quick": + commands: + - echo "quick cmd 1" + - echo "quick cmd 2" + + # Step 5: Test inline function with goto (both execute) + - name: branch-and-jump + type: bash + command: echo "Branch and jump phase" + exports: + jump_mode: "{{scan_mode}}" + decision: + switch: "{{jump_mode}}" + cases: + "quick": + function: "log_info('[INLINE-GOTO] Quick inline before jump')" + goto: final-step + "deep": + function: "log_info('[INLINE-GOTO] Deep inline before jump')" + goto: final-step + default: + function: "log_info('[INLINE-GOTO] Default inline before jump')" + goto: final-step + + # This step should be skipped when goto is used + - name: skipped-step + type: bash + command: echo "[SKIPPED] This should not appear" + + - name: final-step + type: bash + command: echo "[FINAL] Scan complete for {{target}} in {{scan_mode}} mode"