mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-09-11 20:27:47 +02:00
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
This commit is contained in:
+43
-6
@@ -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
|
||||
|
||||
+11
-10
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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}}"
|
||||
+102
@@ -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"
|
||||
Reference in New Issue
Block a user