dagu/internal/core/spec/builder_test.go

4368 lines
108 KiB
Go

package spec_test
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dagu-org/dagu/internal/common/cmdutil"
"github.com/dagu-org/dagu/internal/core"
"github.com/dagu-org/dagu/internal/core/spec"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuild(t *testing.T) {
t.Run("SkipIfSuccessful", func(t *testing.T) {
data := []byte(`
skipIfSuccessful: true
steps:
- "true"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.True(t, th.SkipIfSuccessful)
})
t.Run("ParamsWithSubstitution", func(t *testing.T) {
data := []byte(`
params: "TEST_PARAM $1"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
th.AssertParam(t, "1=TEST_PARAM", "2=TEST_PARAM")
})
t.Run("ParamsWithQuotedValues", func(t *testing.T) {
data := []byte(`
params: x="a b c" y="d e f"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
th.AssertParam(t, "x=a b c", "y=d e f")
})
t.Run("ParamsAsMap", func(t *testing.T) {
data := []byte(`
params:
- FOO: foo
- BAR: bar
- BAZ: "` + "`echo baz`" + `"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
th.AssertParam(t,
"FOO=foo",
"BAR=bar",
"BAZ=baz",
)
})
t.Run("ParamsAsMapOverride", func(t *testing.T) {
data := []byte(`
params:
- FOO: foo
- BAR: bar
- BAZ: "` + "`echo baz`" + `"
`)
dag, err := spec.LoadYAMLWithOpts(context.Background(), data, spec.BuildOpts{Parameters: "FOO=X BAZ=Y"})
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
th.AssertParam(t,
"FOO=X",
"BAR=bar",
"BAZ=Y",
)
})
t.Run("ParamsWithComplexValues", func(t *testing.T) {
data := []byte(`
params: first P1=foo P2=${A001} P3=` + "`/bin/echo BAR`" + ` X=bar Y=${P1} Z="A B C"
env:
- A001: TEXT
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
th.AssertParam(t,
"1=first",
"P1=foo",
"P2=TEXT",
"P3=BAR",
"X=bar",
"Y=foo",
"Z=A B C",
)
})
t.Run("ParamsWithSubstringAndDefaults", func(t *testing.T) {
data := []byte(`
env:
- SOURCE_ID: HBL01_22OCT2025_0536
params:
- BASE: ${SOURCE_ID}
- PREFIX: ${BASE:0:5}
- REMAINDER: ${BASE:5}
- FALLBACK: ${MISSING_VALUE:-fallback}
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
th.AssertParam(t,
"BASE=HBL01_22OCT2025_0536",
"PREFIX=HBL01",
"REMAINDER=_22OCT2025_0536",
"FALLBACK=fallback",
)
})
t.Run("ParamsNoEvalPreservesRaw", func(t *testing.T) {
data := []byte(`
env:
- SOURCE_ID: HBL01_22OCT2025_0536
params:
- BASE: ${SOURCE_ID}
- PREFIX: ${BASE:0:5}
`)
dag, err := spec.LoadYAMLWithOpts(context.Background(), data, spec.BuildOpts{Flags: spec.BuildFlagNoEval})
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
th.AssertParam(t,
"BASE=${SOURCE_ID}",
"PREFIX=${BASE:0:5}",
)
})
t.Run("ParamsWithLocalSchemaReference", func(t *testing.T) {
schemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {
"type": "integer",
"default": 10,
"minimum": 1
},
"environment": {
"type": "string",
"default": "dev",
"enum": ["dev", "staging", "prod"]
}
}
}`
// Create temp schema file
tmpFile, err := os.CreateTemp("", "test-schema-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.WriteString(schemaContent)
require.NoError(t, err)
tmpFile.Close()
data := []byte(fmt.Sprintf(`
params:
schema: "%s"
values:
batch_size: 25
environment: "staging"
`, tmpFile.Name()))
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
// Test that parameters are parsed correctly (order may vary)
require.Len(t, th.Params, 2)
require.Contains(t, th.Params, "batch_size=25")
require.Contains(t, th.Params, "environment=staging")
})
t.Run("ParamsWithRemoteSchemaReference", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/schemas/dag-params.json", func(w http.ResponseWriter, r *http.Request) {
schemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {
"type": "integer",
"default": 10,
"minimum": 1
},
"environment": {
"type": "string",
"default": "dev",
"enum": ["dev", "staging", "prod"]
}
}
}`
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(schemaContent))
})
server := httptest.NewServer(mux)
defer server.Close()
data := []byte(fmt.Sprintf(`
params:
schema: "%s/schemas/dag-params.json"
values:
batch_size: 50
environment: "prod"
`, server.URL))
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
// Test that parameters are parsed correctly (order may vary)
require.Len(t, th.Params, 2)
require.Contains(t, th.Params, "batch_size=50")
require.Contains(t, th.Params, "environment=prod")
})
// Relative path resolution: workingDir, DAG dir, and CWD precedence
t.Run("ParamsSchemaResolutionFromWorkingDir", func(t *testing.T) {
// Schema with a default to prove it was loaded
schemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {"type": "integer", "default": 42}
}
}`
wd := t.TempDir()
wdSchema := filepath.Join(wd, "schema.json")
require.NoError(t, os.WriteFile(wdSchema, []byte(schemaContent), 0600))
origWD, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() {
if err := os.Chdir(origWD); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
})
data := []byte(fmt.Sprintf(`
workingDir: %s
params:
schema: "schema.json"
values:
environment: "dev"
`, wd))
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
// batch_size should be filled by schema default from workingDir schema
require.Contains(t, th.Params, "batch_size=42")
})
t.Run("ParamsSchemaResolutionFromDAGDir", func(t *testing.T) {
// Schema with different default to detect which file is used
schemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {"type": "integer", "default": 7}
}
}`
dir := t.TempDir()
// Write schema.json in same dir as DAG file
require.NoError(t, os.WriteFile(filepath.Join(dir, "schema.json"), []byte(schemaContent), 0600))
dagYaml := []byte(`
params:
schema: "schema.json"
values:
environment: "staging"
`)
dagPath := filepath.Join(dir, "dag.yaml")
require.NoError(t, os.WriteFile(dagPath, dagYaml, 0600))
dag, err := spec.Load(context.Background(), dagPath)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
// batch_size default should come from schema.json next to DAG file
require.Contains(t, th.Params, "batch_size=7")
})
t.Run("ParamsSchemaResolutionPrefersCWDOverWorkingDir", func(t *testing.T) {
// Prepare two schemas with different defaults
cwdSchemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {"type": "integer", "default": 99}
}
}`
wdSchemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {"type": "integer", "default": 11}
}
}`
cwd := t.TempDir()
wd := t.TempDir()
// Write schema.json in both locations
require.NoError(t, os.WriteFile(filepath.Join(cwd, "schema.json"), []byte(cwdSchemaContent), 0600))
require.NoError(t, os.WriteFile(filepath.Join(wd, "schema.json"), []byte(wdSchemaContent), 0600))
// Change process CWD for this subtest and restore after
orig, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(cwd))
defer os.Chdir(orig)
data := []byte(fmt.Sprintf(`
workingDir: %s
params:
schema: "schema.json"
values:
environment: "dev"
`, wd))
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
// Should prefer schema.json from CWD (default 99) over workingDir (default 11)
require.Contains(t, th.Params, "batch_size=99")
})
t.Run("ParamsSkipSchemaValidationFlag", func(t *testing.T) {
data := []byte(`
params:
schema: "missing-schema.json"
values:
foo: "bar"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
dag, err := spec.LoadYAMLWithOpts(context.Background(), data, spec.BuildOpts{
Flags: spec.BuildFlagSkipSchemaValidation,
})
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
th.AssertParam(t, "foo=bar")
})
t.Run("ParamsWithSchemaAndOverrideValidation", func(t *testing.T) {
schemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {
"type": "integer",
"default": 10,
"minimum": 1,
"maximum": 50
},
"environment": {
"type": "string",
"default": "dev",
"enum": ["dev", "staging", "prod"]
}
}
}`
// Create temp schema file
tmpFile, err := os.CreateTemp("", "test-schema-validation-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.WriteString(schemaContent)
require.NoError(t, err)
tmpFile.Close()
data := []byte(fmt.Sprintf(`
params:
schema: "%s"
`, tmpFile.Name()))
// Inject CLI parameters that override the schema values and should fail validation
cliParams := "batch_size=100 environment=prod"
_, err = spec.LoadYAML(context.Background(), data, spec.WithParams(cliParams))
require.Error(t, err)
require.Contains(t, err.Error(), "parameter validation failed")
require.Contains(t, err.Error(), "maximum: 100/1 is greater than 50")
})
t.Run("ParamsWithSchemaDefaultsApplied", func(t *testing.T) {
schemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {
"type": "integer",
"default": 25,
"minimum": 1,
"maximum": 100
},
"environment": {
"type": "string",
"default": "development",
"enum": ["development", "staging", "production"]
},
"debug": {
"type": "boolean",
"default": true
}
}
}`
// Create temp schema file
tmpFile, err := os.CreateTemp("", "test-schema-defaults-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.WriteString(schemaContent)
require.NoError(t, err)
tmpFile.Close()
// Test case 1: Only provide some parameters, let defaults fill the rest
data := []byte(fmt.Sprintf(`
params:
schema: "%s"
values:
batch_size: 75
# environment and debug should get defaults
`, tmpFile.Name()))
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
// Should have all 3 parameters: provided batch_size + defaults for environment and debug
require.Len(t, th.Params, 3)
// Check that provided value remains unchanged
require.Contains(t, th.Params, "batch_size=75")
// Check that defaults were applied
require.Contains(t, th.Params, "environment=development")
require.Contains(t, th.Params, "debug=true")
})
t.Run("ParamsWithSchemaDefaultsPreserveExistingValues", func(t *testing.T) {
schemaContent := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"batch_size": {
"type": "integer",
"default": 25,
"minimum": 1,
"maximum": 100
},
"environment": {
"type": "string",
"default": "development",
"enum": ["development", "staging", "production"]
},
"debug": {
"type": "boolean",
"default": true
},
"timeout": {
"type": "integer",
"default": 300
}
}
}`
// Create temp schema file
tmpFile, err := os.CreateTemp("", "test-schema-preserve-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.WriteString(schemaContent)
require.NoError(t, err)
tmpFile.Close()
// Provide all parameters explicitly - defaults should NOT override them
data := []byte(fmt.Sprintf(`
params:
schema: "%s"
values:
batch_size: 50
environment: "production"
debug: false
timeout: 600
`, tmpFile.Name()))
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
// Should have all 4 parameters with their explicitly provided values
require.Len(t, th.Params, 4)
// Check that all explicitly provided values remain unchanged (defaults should not override)
require.Contains(t, th.Params, "batch_size=50")
require.Contains(t, th.Params, "environment=production")
require.Contains(t, th.Params, "debug=false")
require.Contains(t, th.Params, "timeout=600")
})
t.Run("MailOn", func(t *testing.T) {
data := []byte(`
steps:
- "true"
mailOn:
failure: true
success: true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.True(t, th.MailOn.Failure)
assert.True(t, th.MailOn.Success)
})
t.Run("ValidTags", func(t *testing.T) {
data := []byte(`
tags: daily,monthly
steps:
- echo 1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.True(t, th.HasTag("daily"))
assert.True(t, th.HasTag("monthly"))
})
t.Run("ValidTagsList", func(t *testing.T) {
data := []byte(`
tags:
- daily
- monthly
steps:
- echo 1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.True(t, th.HasTag("daily"))
assert.True(t, th.HasTag("monthly"))
})
t.Run("LogDir", func(t *testing.T) {
data := []byte(`
logDir: /tmp/logs
steps:
- "true"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, "/tmp/logs", th.LogDir)
})
t.Run("MailConfig", func(t *testing.T) {
data := []byte(`
# SMTP server settings
smtp:
host: "smtp.example.com"
port: "587"
username: user@example.com
password: password
# Error mail configuration
errorMail:
from: "error@example.com"
to: "admin@example.com"
prefix: "[ERROR]"
attachLogs: true
# Info mail configuration
infoMail:
from: "info@example.com"
to: "user@example.com"
prefix: "[INFO]"
attachLogs: true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, "smtp.example.com", th.SMTP.Host)
assert.Equal(t, "587", th.SMTP.Port)
assert.Equal(t, "user@example.com", th.SMTP.Username)
assert.Equal(t, "password", th.SMTP.Password)
assert.Equal(t, "error@example.com", th.ErrorMail.From)
assert.Equal(t, []string{"admin@example.com"}, th.ErrorMail.To)
assert.Equal(t, "[ERROR]", th.ErrorMail.Prefix)
assert.True(t, th.ErrorMail.AttachLogs)
assert.Equal(t, "info@example.com", th.InfoMail.From)
assert.Equal(t, []string{"user@example.com"}, th.InfoMail.To)
assert.Equal(t, "[INFO]", th.InfoMail.Prefix)
assert.True(t, th.InfoMail.AttachLogs)
})
t.Run("SMTPNumericPort", func(t *testing.T) {
// Test SMTP configuration with numeric port
data := []byte(`
smtp:
host: "smtp.example.com"
port: 587
username: "user@example.com"
password: "password"
steps:
- echo test
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.NotNil(t, dag.SMTP)
assert.Equal(t, "smtp.example.com", dag.SMTP.Host)
assert.Equal(t, "587", dag.SMTP.Port)
assert.Equal(t, "user@example.com", dag.SMTP.Username)
assert.Equal(t, "password", dag.SMTP.Password)
})
t.Run("MailConfigMultipleRecipients", func(t *testing.T) {
data := []byte(`
# SMTP server settings
smtp:
host: "smtp.example.com"
port: "587"
username: user@example.com
password: password
# Error mail with multiple recipients
errorMail:
from: "error@example.com"
to:
- "admin1@example.com"
- "admin2@example.com"
- "admin3@example.com"
prefix: "[ERROR]"
attachLogs: true
# Info mail with single recipient as array
infoMail:
from: "info@example.com"
to:
- "user@example.com"
prefix: "[INFO]"
attachLogs: false
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
// Check error mail with multiple recipients
assert.Equal(t, "error@example.com", th.ErrorMail.From)
assert.Equal(t, []string{"admin1@example.com", "admin2@example.com", "admin3@example.com"}, th.ErrorMail.To)
assert.Equal(t, "[ERROR]", th.ErrorMail.Prefix)
assert.True(t, th.ErrorMail.AttachLogs)
// Check info mail with single recipient as array
assert.Equal(t, "info@example.com", th.InfoMail.From)
assert.Equal(t, []string{"user@example.com"}, th.InfoMail.To)
assert.Equal(t, "[INFO]", th.InfoMail.Prefix)
assert.False(t, th.InfoMail.AttachLogs)
})
t.Run("MaxHistRetentionDays", func(t *testing.T) {
data := []byte(`
histRetentionDays: 365
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, 365, th.HistRetentionDays)
})
t.Run("CleanUpTime", func(t *testing.T) {
data := []byte(`
maxCleanUpTimeSec: 10
steps:
- "true"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, time.Duration(10*time.Second), th.MaxCleanUpTime)
})
t.Run("ChainTypeBasic", func(t *testing.T) {
data := []byte(`
type: chain
steps:
- echo "First"
- echo "Second"
- echo "Third"
- echo "Fourth"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, core.TypeChain, th.Type)
// Check that implicit dependencies were added
assert.Len(t, th.Steps, 4)
assert.Empty(t, th.Steps[0].Depends) // First step has no dependencies
assert.Equal(t, []string{"cmd_1"}, th.Steps[1].Depends)
assert.Equal(t, []string{"cmd_2"}, th.Steps[2].Depends)
assert.Equal(t, []string{"cmd_3"}, th.Steps[3].Depends)
})
t.Run("ChainTypeWithExplicitDepends", func(t *testing.T) {
data := []byte(`
type: chain
steps:
- name: setup
command: ./setup.sh
- name: download-a
command: wget fileA
- name: download-b
command: wget fileB
- name: process-both
command: process.py fileA fileB
depends: # Override chain to depend on both downloads
- download-a
- download-b
- name: cleanup
command: rm -f fileA fileB
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, core.TypeChain, th.Type)
// Check dependencies
assert.Len(t, th.Steps, 5)
assert.Empty(t, th.Steps[0].Depends) // setup
assert.Equal(t, []string{"setup"}, th.Steps[1].Depends) // download-a
assert.Equal(t, []string{"download-a"}, th.Steps[2].Depends) // download-b
// process-both should keep its explicit dependencies
assert.ElementsMatch(t, []string{"download-a", "download-b"}, th.Steps[3].Depends)
assert.Equal(t, []string{"process-both"}, th.Steps[4].Depends) // cleanup
})
t.Run("InvalidType", func(t *testing.T) {
// Test will fail with an error containing "invalid type"
data := []byte(`
type: invalid-type
steps:
- name: step1
command: echo "test"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid type")
})
t.Run("DefaultTypeIsChain", func(t *testing.T) {
data := []byte(`
steps:
- echo 1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, core.TypeChain, th.Type)
})
t.Run("ChainTypeWithNoDependencies", func(t *testing.T) {
data := []byte(`
type: chain
steps:
- name: step1
command: echo "First"
- name: step2
command: echo "Second - should depend on step1"
- name: step3
command: echo "Third - no dependencies"
depends: [] # Explicitly no dependencies
- name: step4
command: echo "Fourth - should depend on step3"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, core.TypeChain, th.Type)
// Check dependencies
assert.Len(t, th.Steps, 4)
assert.Empty(t, th.Steps[0].Depends) // step1
assert.Equal(t, []string{"step1"}, th.Steps[1].Depends) // step2
assert.Empty(t, th.Steps[2].Depends) // step3 - explicitly no deps
assert.Equal(t, []string{"step3"}, th.Steps[3].Depends) // step4 should depend on step3
})
t.Run("Preconditions", func(t *testing.T) {
data := []byte(`
preconditions:
- condition: "test -f file.txt"
expected: "true"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Preconditions, 1)
assert.Equal(t, &core.Condition{Condition: "test -f file.txt", Expected: "true"}, th.Preconditions[0])
})
t.Run("PreconditionsWithNegate", func(t *testing.T) {
data := []byte(`
preconditions:
- condition: "${STATUS}"
expected: "success"
negate: true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Preconditions, 1)
assert.Equal(t, &core.Condition{Condition: "${STATUS}", Expected: "success", Negate: true}, th.Preconditions[0])
})
t.Run("MaxActiveRuns", func(t *testing.T) {
data := []byte(`
maxActiveRuns: 5
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, 5, th.MaxActiveRuns)
})
t.Run("MaxActiveSteps", func(t *testing.T) {
data := []byte(`
maxActiveSteps: 3
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, 3, th.MaxActiveSteps)
})
t.Run("RunConfig", func(t *testing.T) {
data := []byte(`
runConfig:
disableParamEdit: true
disableRunIdEdit: true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.NotNil(t, dag.RunConfig)
assert.True(t, dag.RunConfig.DisableParamEdit)
assert.True(t, dag.RunConfig.DisableRunIdEdit)
})
t.Run("MaxOutputSize", func(t *testing.T) {
// Test custom maxOutputSize
data := []byte(`
description: Test DAG with custom maxOutputSize
# Custom maxOutputSize of 512KB
maxOutputSize: 524288
steps:
- echo "test output"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Equal(t, 524288, th.MaxOutputSize) // 512KB
// Test default maxOutputSize when not specified
data2 := []byte(`
steps:
- "true"
`)
dag2, err := spec.LoadYAML(context.Background(), data2)
require.NoError(t, err)
th2 := DAG{t: t, DAG: dag2}
assert.Equal(t, 0, th2.MaxOutputSize) // Default 1MB
})
t.Run("ValidationError", func(t *testing.T) {
type testCase struct {
name string
yaml string
expectedErr error
}
testCases := []testCase{
{
name: "InvalidEnv",
yaml: `
env:
- VAR: "` + "`invalid command`" + `"`,
expectedErr: spec.ErrInvalidEnvValue,
},
{
name: "InvalidParams",
yaml: `
params: "` + "`invalid command`" + `"`,
expectedErr: spec.ErrInvalidParamValue,
},
{
name: "InvalidSchedule",
yaml: `
schedule: "1"`,
expectedErr: spec.ErrInvalidSchedule,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
data := []byte(tc.yaml)
ctx := context.Background()
_, err := spec.LoadYAML(ctx, data)
if errs, ok := err.(*core.ErrorList); ok && len(*errs) > 0 {
found := false
for _, e := range *errs {
if errors.Is(e, tc.expectedErr) {
found = true
break
}
}
require.True(t, found, "expected error %v, got %v", tc.expectedErr, err)
} else {
assert.ErrorIs(t, err, tc.expectedErr)
}
})
}
})
}
func TestBuildEnv(t *testing.T) {
t.Parallel()
type testCase struct {
name string
yaml string
expected map[string]string
}
testCases := []testCase{
{
name: "ValidEnv",
yaml: `
env:
- FOO: "123"
steps:
- "true"
`,
expected: map[string]string{
"FOO": "123",
},
},
{
name: "ValidEnvWithSubstitution",
yaml: `
env:
- VAR: "` + "`echo 123`" + `"
steps:
- "true"
`,
expected: map[string]string{
"VAR": "123",
},
},
{
name: "ValidEnvWithSubstitutionAndEnv",
yaml: `
env:
- BEE: "BEE"
- BAZ: "BAZ"
- BOO: "BOO"
- FOO: "${BEE}:${BAZ}:${BOO}:FOO"
steps:
- "true"
`,
expected: map[string]string{
"FOO": "BEE:BAZ:BOO:FOO",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dag, err := spec.LoadYAML(context.Background(), []byte(tc.yaml))
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
for key, val := range tc.expected {
th.AssertEnv(t, key, val)
}
})
}
}
func TestBuildSchedule(t *testing.T) {
t.Parallel()
type testCase struct {
name string
yaml string
start []string
stop []string
restart []string
}
testCases := []testCase{
{
name: "ValidSchedule",
yaml: `
schedule:
start: "0 1 * * *"
stop: "0 2 * * *"
restart: "0 12 * * *"
steps:
- "true"
`,
start: []string{"0 1 * * *"},
stop: []string{"0 2 * * *"},
restart: []string{"0 12 * * *"},
},
{
name: "ListSchedule",
yaml: `
schedule:
- "0 1 * * *"
- "0 18 * * *"
steps:
- "true"
`,
start: []string{
"0 1 * * *",
"0 18 * * *",
},
},
{
name: "MultipleValues",
yaml: `
schedule:
start:
- "0 1 * * *"
- "0 18 * * *"
stop:
- "0 2 * * *"
- "0 20 * * *"
restart:
- "0 12 * * *"
- "0 22 * * *"
steps:
- "true"
`,
start: []string{
"0 1 * * *",
"0 18 * * *",
},
stop: []string{
"0 2 * * *",
"0 20 * * *",
},
restart: []string{
"0 12 * * *",
"0 22 * * *",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dag, err := spec.LoadYAML(context.Background(), []byte(tc.yaml))
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Schedule, len(tc.start))
for i, s := range tc.start {
assert.Equal(t, s, th.Schedule[i].Expression)
}
assert.Len(t, th.StopSchedule, len(tc.stop))
for i, s := range tc.stop {
assert.Equal(t, s, th.StopSchedule[i].Expression)
}
assert.Len(t, th.RestartSchedule, len(tc.restart))
for i, s := range tc.restart {
assert.Equal(t, s, th.RestartSchedule[i].Expression)
}
})
}
}
func TestBuildStep(t *testing.T) {
t.Parallel()
t.Run("ValidCommand", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: echo 1
name: step1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Equal(t, "echo 1", th.Steps[0].CmdWithArgs)
assert.Equal(t, "echo", th.Steps[0].Command)
assert.Equal(t, []string{"1"}, th.Steps[0].Args)
assert.Equal(t, "step1", th.Steps[0].Name)
})
t.Run("CommandAsScript", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: |
echo hello
echo world
name: script
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
require.Len(t, th.Steps, 1)
step := th.Steps[0]
assert.Equal(t, "script", step.Name)
assert.Equal(t, "echo hello\necho world", step.Script)
assert.Empty(t, step.Command)
assert.Empty(t, step.CmdWithArgs)
assert.Empty(t, step.CmdArgsSys)
assert.Nil(t, step.Args)
})
t.Run("ValidCommandInArray", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: [echo, 1]
name: step1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Equal(t,
cmdutil.JoinCommandArgs("echo", []string{"1"}),
th.Steps[0].CmdArgsSys)
assert.Equal(t, "echo", th.Steps[0].Command)
assert.Equal(t, []string{"1"}, th.Steps[0].Args)
assert.Equal(t, "step1", th.Steps[0].Name)
})
t.Run("ValidCommandInList", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command:
- echo
- 1
name: step1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Equal(t,
cmdutil.JoinCommandArgs("echo", []string{"1"}),
th.Steps[0].CmdArgsSys)
assert.Equal(t, "echo", th.Steps[0].Command)
assert.Equal(t, []string{"1"}, th.Steps[0].Args)
assert.Equal(t, "step1", th.Steps[0].Name)
})
t.Run("HTTPExecutor", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: GET http://example.com
name: step1
executor: http
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Equal(t, "http", th.Steps[0].ExecutorConfig.Type)
})
t.Run("HTTPExecutorWithConfig", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: http://example.com
name: step1
executor:
type: http
config:
key: value
map:
foo: bar
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Equal(t, "http", th.Steps[0].ExecutorConfig.Type)
assert.Equal(t, map[string]any{
"key": "value",
"map": map[string]any{
"foo": "bar",
},
}, th.Steps[0].ExecutorConfig.Config)
})
t.Run("DAGExecutor", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: execute a sub-dag
call: sub_dag
params: "param1=value1 param2=value2"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Equal(t, "dag", th.Steps[0].ExecutorConfig.Type)
assert.Equal(t, "call", th.Steps[0].Command)
assert.Equal(t, []string{
"sub_dag",
"param1=\"value1\" param2=\"value2\"",
}, th.Steps[0].Args)
assert.Equal(t, "sub_dag param1=\"value1\" param2=\"value2\"", th.Steps[0].CmdWithArgs)
assert.Empty(t, dag.BuildWarnings)
// Legacy run field is still accepted
dataLegacy := []byte(`
steps:
- name: legacy sub-dag
run: sub_dag_legacy
`)
dagLegacy, err := spec.LoadYAML(context.Background(), dataLegacy)
require.NoError(t, err)
thLegacy := DAG{t: t, DAG: dagLegacy}
assert.Len(t, thLegacy.Steps, 1)
assert.Equal(t, "dag", thLegacy.Steps[0].ExecutorConfig.Type)
assert.Equal(t, "call", thLegacy.Steps[0].Command)
assert.Equal(t, []string{"sub_dag_legacy", ""}, thLegacy.Steps[0].Args)
assert.Equal(t, "sub_dag_legacy", thLegacy.Steps[0].CmdWithArgs)
require.Len(t, dagLegacy.BuildWarnings, 1)
assert.Contains(t, dagLegacy.BuildWarnings[0], "Step field 'run' is deprecated")
})
t.Run("ContinueOn", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn:
skipped: true
failure: true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.True(t, th.Steps[0].ContinueOn.Failure)
assert.True(t, th.Steps[0].ContinueOn.Skipped)
})
t.Run("ContinueOnStringSkipped", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn: skipped
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.True(t, th.Steps[0].ContinueOn.Skipped)
assert.False(t, th.Steps[0].ContinueOn.Failure)
})
t.Run("ContinueOnStringFailed", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn: failed
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.False(t, th.Steps[0].ContinueOn.Skipped)
assert.True(t, th.Steps[0].ContinueOn.Failure)
})
t.Run("ContinueOnStringCaseInsensitive", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn: SKIPPED
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.True(t, th.Steps[0].ContinueOn.Skipped)
})
t.Run("ContinueOnInvalidString", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn: invalid
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "continueOn")
})
t.Run("ContinueOnObjectWithExitCode", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn:
exitCode: [1, 2, 3]
markSuccess: true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Equal(t, []int{1, 2, 3}, th.Steps[0].ContinueOn.ExitCode)
assert.True(t, th.Steps[0].ContinueOn.MarkSuccess)
})
t.Run("ContinueOnInvalidFailureType", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn:
failure: "true"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "continueOn.failure")
assert.Contains(t, err.Error(), "boolean")
})
t.Run("ContinueOnInvalidSkippedType", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn:
skipped: 1
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "continueOn.skipped")
assert.Contains(t, err.Error(), "boolean")
})
t.Run("ContinueOnInvalidMarkSuccessType", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 1"
continueOn:
markSuccess: "yes"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "continueOn.markSuccess")
assert.Contains(t, err.Error(), "boolean")
})
t.Run("RetryPolicy", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: "echo 2"
retryPolicy:
limit: 3
intervalSec: 10
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
require.NotNil(t, th.Steps[0].RetryPolicy)
assert.Equal(t, 3, th.Steps[0].RetryPolicy.Limit)
assert.Equal(t, 10*time.Second, th.Steps[0].RetryPolicy.Interval)
})
t.Run("RetryPolicyWithBackoff", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "test_backoff"
command: "echo test"
retryPolicy:
limit: 5
intervalSec: 2
backoff: 2.0
maxIntervalSec: 30
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
require.NotNil(t, th.Steps[0].RetryPolicy)
assert.Equal(t, 5, th.Steps[0].RetryPolicy.Limit)
assert.Equal(t, 2*time.Second, th.Steps[0].RetryPolicy.Interval)
assert.Equal(t, 2.0, th.Steps[0].RetryPolicy.Backoff)
assert.Equal(t, 30*time.Second, th.Steps[0].RetryPolicy.MaxInterval)
})
t.Run("RetryPolicyWithBackoffBool", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "test_backoff_bool"
command: "echo test"
retryPolicy:
limit: 3
intervalSec: 1
backoff: true
maxIntervalSec: 10
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
require.NotNil(t, th.Steps[0].RetryPolicy)
assert.Equal(t, 3, th.Steps[0].RetryPolicy.Limit)
assert.Equal(t, 1*time.Second, th.Steps[0].RetryPolicy.Interval)
assert.Equal(t, 2.0, th.Steps[0].RetryPolicy.Backoff) // true converts to 2.0
assert.Equal(t, 10*time.Second, th.Steps[0].RetryPolicy.MaxInterval)
})
t.Run("RetryPolicyInvalidBackoff", func(t *testing.T) {
t.Parallel()
// Test backoff value <= 1.0
data := []byte(`
steps:
- name: "test"
command: "echo test"
retryPolicy:
limit: 3
intervalSec: 1
backoff: 0.8
`)
dag, err := spec.LoadYAML(context.Background(), data)
assert.Error(t, err)
assert.Nil(t, dag)
assert.Contains(t, err.Error(), "backoff must be greater than 1.0")
})
t.Run("RepeatPolicy", func(t *testing.T) {
t.Parallel()
// Test basic boolean repeat (backward compatibility)
data := []byte(`
steps:
- command: "echo 2"
repeatPolicy:
repeat: true
intervalSec: 60
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
require.NotNil(t, th.Steps[0].RepeatPolicy)
assert.Equal(t, core.RepeatModeWhile, th.Steps[0].RepeatPolicy.RepeatMode)
assert.Equal(t, 60*time.Second, th.Steps[0].RepeatPolicy.Interval)
assert.Equal(t, 0, th.Steps[0].RepeatPolicy.Limit) // No limit set
})
t.Run("RepeatPolicyWhileCondition", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "repeat-while-condition"
command: "echo test"
repeatPolicy:
repeat: "while"
condition: "echo hello"
intervalSec: 5
limit: 3
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, core.RepeatModeWhile, repeatPolicy.RepeatMode)
require.NotNil(t, repeatPolicy.Condition)
assert.Equal(t, "echo hello", repeatPolicy.Condition.Condition)
assert.Equal(t, "", repeatPolicy.Condition.Expected) // No expected value for while mode
assert.Equal(t, 5*time.Second, repeatPolicy.Interval)
assert.Equal(t, 3, repeatPolicy.Limit)
})
t.Run("RepeatPolicyUntilCondition", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "repeat-until-condition"
command: "echo test"
repeatPolicy:
repeat: "until"
condition: "echo hello"
expected: "hello"
intervalSec: 10
limit: 5
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, core.RepeatModeUntil, repeatPolicy.RepeatMode)
require.NotNil(t, repeatPolicy.Condition)
assert.Equal(t, "echo hello", repeatPolicy.Condition.Condition)
assert.Equal(t, "hello", repeatPolicy.Condition.Expected)
assert.Equal(t, 10*time.Second, repeatPolicy.Interval)
assert.Equal(t, 5, repeatPolicy.Limit)
})
t.Run("RepeatPolicyWhileExitCode", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "repeat-while-exitcode"
command: "exit 1"
repeatPolicy:
repeat: "while"
exitCode: [1, 2]
intervalSec: 15
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, core.RepeatModeWhile, repeatPolicy.RepeatMode)
assert.Equal(t, []int{1, 2}, repeatPolicy.ExitCode)
assert.Equal(t, 15*time.Second, repeatPolicy.Interval)
assert.Nil(t, repeatPolicy.Condition) // No condition set
})
t.Run("RepeatPolicyUntilExitCode", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "repeat-until-exitcode"
command: "exit 0"
repeatPolicy:
repeat: "until"
exitCode: [0]
intervalSec: 20
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, core.RepeatModeUntil, repeatPolicy.RepeatMode)
assert.Equal(t, []int{0}, repeatPolicy.ExitCode)
assert.Equal(t, 20*time.Second, repeatPolicy.Interval)
assert.Nil(t, repeatPolicy.Condition) // No condition set
})
t.Run("RepeatPolicyBackwardCompatibilityUntil", func(t *testing.T) {
t.Parallel()
// Test backward compatibility: condition + expected should infer "until" mode
data := []byte(`
steps:
- name: "repeat-backward-compatibility-until"
command: "echo test"
repeatPolicy:
condition: "echo hello"
expected: "hello"
intervalSec: 25
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, core.RepeatModeUntil, repeatPolicy.RepeatMode)
require.NotNil(t, repeatPolicy.Condition)
assert.Equal(t, "echo hello", repeatPolicy.Condition.Condition)
assert.Equal(t, "hello", repeatPolicy.Condition.Expected)
assert.Equal(t, 25*time.Second, repeatPolicy.Interval)
})
t.Run("RepeatPolicyBackwardCompatibilityWhile", func(t *testing.T) {
t.Parallel()
// Test backward compatibility: condition only should infer "while" mode
data := []byte(`
steps:
- name: "repeat-backward-compatibility-while"
command: "echo test"
repeatPolicy:
condition: "echo hello"
intervalSec: 30
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, core.RepeatModeWhile, repeatPolicy.RepeatMode)
require.NotNil(t, repeatPolicy.Condition)
assert.Equal(t, "echo hello", repeatPolicy.Condition.Condition)
assert.Equal(t, "", repeatPolicy.Condition.Expected) // No expected value
assert.Equal(t, 30*time.Second, repeatPolicy.Interval)
})
t.Run("RepeatPolicyCondition", func(t *testing.T) {
t.Parallel()
// Test existing backward compatibility condition test
data := []byte(`
steps:
- name: "repeat-condition"
command: "echo hello"
repeatPolicy:
condition: "echo hello"
expected: "hello"
intervalSec: 1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy.Condition)
assert.Equal(t, "echo hello", repeatPolicy.Condition.Condition)
assert.Equal(t, "hello", repeatPolicy.Condition.Expected)
assert.Equal(t, 1*time.Second, repeatPolicy.Interval)
// Should infer "until" mode due to condition + expected
assert.Equal(t, core.RepeatModeUntil, repeatPolicy.RepeatMode)
})
t.Run("SignalOnStop", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: echo 1
name: step1
signalOnStop: SIGINT
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Equal(t, "SIGINT", th.Steps[0].SignalOnStop)
})
t.Run("StepWithID", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: unique-step-1
command: echo "Step with ID"
- name: step2
command: echo "Step without ID"
- name: step3
id: custom-id-123
command: echo "Another step with ID"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 3)
// First step has ID
assert.Equal(t, "step1", th.Steps[0].Name)
assert.Equal(t, "unique-step-1", th.Steps[0].ID)
// Second step has no ID
assert.Equal(t, "step2", th.Steps[1].Name)
assert.Equal(t, "", th.Steps[1].ID)
// Third step has ID
assert.Equal(t, "step3", th.Steps[2].Name)
assert.Equal(t, "custom-id-123", th.Steps[2].ID)
})
t.Run("Preconditions", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "2"
command: "echo 2"
preconditions:
- condition: "test -f file.txt"
expected: "true"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Len(t, th.Steps[0].Preconditions, 1)
assert.Equal(t, &core.Condition{Condition: "test -f file.txt", Expected: "true"}, th.Steps[0].Preconditions[0])
})
t.Run("StepPreconditionsWithNegate", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "step_with_negate"
command: "echo hello"
preconditions:
- condition: "${STATUS}"
expected: "success"
negate: true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
assert.Len(t, th.Steps[0].Preconditions, 1)
assert.Equal(t, &core.Condition{Condition: "${STATUS}", Expected: "success", Negate: true}, th.Steps[0].Preconditions[0])
})
t.Run("RepeatPolicyExitCode", func(t *testing.T) {
t.Parallel()
// Test existing backward compatibility exitcode test
data := []byte(`
steps:
- name: "repeat-exitcode"
command: "exit 42"
repeatPolicy:
exitCode: [42]
intervalSec: 2
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, []int{42}, repeatPolicy.ExitCode)
assert.Equal(t, 2*time.Second, repeatPolicy.Interval)
// Should infer "while" mode due to exitCode only
assert.Equal(t, core.RepeatModeWhile, repeatPolicy.RepeatMode)
})
t.Run("RepeatPolicyWithBackoff", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "test_repeat_backoff"
command: "echo test"
repeatPolicy:
repeat: while
intervalSec: 5
backoff: 1.5
maxIntervalSec: 60
limit: 10
exitCode: [1]
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, core.RepeatModeWhile, repeatPolicy.RepeatMode)
assert.Equal(t, 5*time.Second, repeatPolicy.Interval)
assert.Equal(t, 1.5, repeatPolicy.Backoff)
assert.Equal(t, 60*time.Second, repeatPolicy.MaxInterval)
assert.Equal(t, 10, repeatPolicy.Limit)
assert.Equal(t, []int{1}, repeatPolicy.ExitCode)
})
t.Run("RepeatPolicyWithBackoffBool", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: "test_repeat_backoff_bool"
command: "echo test"
repeatPolicy:
repeat: until
intervalSec: 2
backoff: true
maxIntervalSec: 20
limit: 5
condition: "echo done"
expected: "done"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
assert.Len(t, th.Steps, 1)
repeatPolicy := th.Steps[0].RepeatPolicy
require.NotNil(t, repeatPolicy)
assert.Equal(t, core.RepeatModeUntil, repeatPolicy.RepeatMode)
assert.Equal(t, 2*time.Second, repeatPolicy.Interval)
assert.Equal(t, 2.0, repeatPolicy.Backoff) // true converts to 2.0
assert.Equal(t, 20*time.Second, repeatPolicy.MaxInterval)
assert.Equal(t, 5, repeatPolicy.Limit)
require.NotNil(t, repeatPolicy.Condition)
assert.Equal(t, "echo done", repeatPolicy.Condition.Condition)
assert.Equal(t, "done", repeatPolicy.Condition.Expected)
})
t.Run("RepeatPolicyErrorCases", func(t *testing.T) {
t.Parallel()
// Test invalid repeat value
data := []byte(`
steps:
- name: "invalid-repeat"
command: "echo test"
repeatPolicy:
repeat: "invalid"
intervalSec: 10
`)
dag, err := spec.LoadYAML(context.Background(), data)
assert.Error(t, err)
assert.Nil(t, dag)
assert.Contains(t, err.Error(), "invalid value for repeat: 'invalid'")
// Test explicit while mode without condition or exitCode
data = []byte(`
steps:
- name: "while-no-condition"
command: "echo test"
repeatPolicy:
repeat: "while"
intervalSec: 10
`)
dag, err = spec.LoadYAML(context.Background(), data)
assert.Error(t, err)
assert.Nil(t, dag)
assert.Contains(t, err.Error(), "repeat mode 'while' requires either 'condition' or 'exitCode' to be specified")
// Test explicit until mode without condition or exitCode
data = []byte(`
steps:
- name: "until-no-condition"
command: "echo test"
repeatPolicy:
repeat: "until"
intervalSec: 10
`)
dag, err = spec.LoadYAML(context.Background(), data)
assert.Error(t, err)
assert.Nil(t, dag)
assert.Contains(t, err.Error(), "repeat mode 'until' requires either 'condition' or 'exitCode' to be specified")
// Test invalid repeat type (not string or bool)
data = []byte(`
steps:
- name: "invalid-type"
command: "echo test"
repeatPolicy:
repeat: 123
intervalSec: 10
`)
dag, err = spec.LoadYAML(context.Background(), data)
assert.Error(t, err)
assert.Nil(t, dag)
assert.Contains(t, err.Error(), "invalid value for repeat")
})
t.Run("PolicyBackoffValidation", func(t *testing.T) {
t.Parallel()
// Test repeat policy invalid backoff
data := []byte(`
steps:
- name: "test"
command: "echo test"
repeatPolicy:
repeat: "while"
intervalSec: 1
backoff: 1.0
exitCode: [1]
`)
dag, err := spec.LoadYAML(context.Background(), data)
assert.Error(t, err)
assert.Nil(t, dag)
assert.Contains(t, err.Error(), "backoff must be greater than 1.0")
// Test with backoff = 0.5
data = []byte(`
steps:
- name: "test"
command: "echo test"
repeatPolicy:
repeat: "while"
intervalSec: 1
backoff: 0.5
exitCode: [1]
`)
dag, err = spec.LoadYAML(context.Background(), data)
assert.Error(t, err)
assert.Nil(t, dag)
assert.Contains(t, err.Error(), "backoff must be greater than 1.0")
})
}
type DAG struct {
t *testing.T
*core.DAG
}
func (th *DAG) AssertEnv(t *testing.T, key, val string) {
th.t.Helper()
expected := key + "=" + val
for _, env := range th.Env {
if env == expected {
return
}
}
t.Errorf("expected env %s=%s not found", key, val)
for i, env := range th.Env {
// print all envs that were found for debugging
t.Logf("env[%d]: %s", i, env)
}
}
func (th *DAG) AssertParam(t *testing.T, params ...string) {
th.t.Helper()
assert.Len(t, th.Params, len(params), "expected %d params, got %d", len(params), len(th.Params))
for i, p := range params {
assert.Equal(t, p, th.Params[i])
}
}
// testLoad and helper functions have been removed - all tests now use inline YAML
func TestBuild_QueueConfiguration(t *testing.T) {
t.Parallel()
t.Run("MaxActiveRunsDefaultsToOne", func(t *testing.T) {
t.Parallel()
// Test that when maxActiveRuns is not specified, it defaults to 1
data := []byte(`
steps:
- command: echo 1
name: step1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag} // Using a simple DAG without maxActiveRuns
assert.Equal(t, 1, th.MaxActiveRuns, "maxActiveRuns should default to 1 when not specified")
})
t.Run("MaxActiveRunsNegativeValuePreserved", func(t *testing.T) {
t.Parallel()
// Test that negative values are preserved (they mean queueing is disabled)
// Create a simple DAG YAML with negative maxActiveRuns
data := []byte(`
maxActiveRuns: -1
steps:
- name: step1
command: echo test
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, -1, dag.MaxActiveRuns, "negative maxActiveRuns should be preserved")
})
}
func TestNestedArrayParallelSyntax(t *testing.T) {
t.Parallel()
t.Run("SimpleParallelSteps", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- echo "step 1"
-
- echo "parallel 1"
- echo "parallel 2"
- echo "step 3"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Len(t, dag.Steps, 4)
// First step (sequential)
assert.Equal(t, "cmd_1", dag.Steps[0].Name)
assert.Equal(t, "echo \"step 1\"", dag.Steps[0].CmdWithArgs)
assert.Empty(t, dag.Steps[0].Depends)
// Parallel steps
assert.Equal(t, "cmd_2", dag.Steps[1].Name)
assert.Equal(t, "echo \"parallel 1\"", dag.Steps[1].CmdWithArgs)
assert.Equal(t, []string{"cmd_1"}, dag.Steps[1].Depends)
assert.Equal(t, "cmd_3", dag.Steps[2].Name)
assert.Equal(t, "echo \"parallel 2\"", dag.Steps[2].CmdWithArgs)
assert.Equal(t, []string{"cmd_1"}, dag.Steps[2].Depends)
// Last step (sequential, depends on both parallel steps)
assert.Equal(t, "cmd_4", dag.Steps[3].Name)
assert.Equal(t, "echo \"step 3\"", dag.Steps[3].CmdWithArgs)
assert.Contains(t, dag.Steps[3].Depends, "cmd_2")
assert.Contains(t, dag.Steps[3].Depends, "cmd_3")
})
t.Run("MixedParallelAndNormalSyntax", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: setup
command: echo "setup"
-
- echo "parallel 1"
- name: test
command: npm test
- name: cleanup
command: echo "cleanup"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Len(t, dag.Steps, 4)
// Setup step
assert.Equal(t, "setup", dag.Steps[0].Name)
assert.Empty(t, dag.Steps[0].Depends)
// Parallel steps
assert.Equal(t, "cmd_2", dag.Steps[1].Name)
assert.Equal(t, []string{"setup"}, dag.Steps[1].Depends)
assert.Equal(t, "test", dag.Steps[2].Name)
assert.Equal(t, []string{"setup"}, dag.Steps[2].Depends)
// Cleanup step
assert.Equal(t, "cleanup", dag.Steps[3].Name)
assert.Contains(t, dag.Steps[3].Depends, "cmd_2")
assert.Contains(t, dag.Steps[3].Depends, "test")
})
t.Run("ParallelStepsWithExplicitDependencies", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
command: echo "1"
- name: step2
command: echo "2"
-
- name: parallel1
command: echo "p1"
depends: [step1] # Explicit dependency overrides automatic
- name: parallel2
command: echo "p2"
# This will get automatic dependency on step2
- name: final
command: echo "done"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Len(t, dag.Steps, 5)
// Parallel1 has explicit dependency on step1
parallel1 := dag.Steps[2]
assert.Equal(t, "parallel1", parallel1.Name)
assert.Equal(t, []string{"step1", "step2"}, parallel1.Depends)
// Parallel2 gets automatic dependency on step2
parallel2 := dag.Steps[3]
assert.Equal(t, "parallel2", parallel2.Name)
assert.Equal(t, []string{"step2"}, parallel2.Depends)
// Final depends on both parallel steps
final := dag.Steps[4]
assert.Equal(t, "final", final.Name)
assert.Contains(t, final.Depends, "parallel1")
assert.Contains(t, final.Depends, "parallel2")
})
t.Run("OnlyParallelSteps", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
-
- echo "parallel 1"
- echo "parallel 2"
- echo "parallel 3"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Len(t, dag.Steps, 3)
// All steps should have no dependencies (first group)
assert.Equal(t, "cmd_1", dag.Steps[0].Name)
// Note: Due to the way dependencies are handled, these may have dependencies on each other
// The important thing is they work in parallel since they don't have external dependencies
assert.Equal(t, "cmd_2", dag.Steps[1].Name)
assert.Equal(t, "cmd_3", dag.Steps[2].Name)
})
t.Run("ConsequentParallelSteps", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
-
- echo "parallel 1"
- echo "parallel 2"
-
- echo "parallel 3"
- echo "parallel 4"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Len(t, dag.Steps, 4)
assert.Equal(t, "cmd_1", dag.Steps[0].Name)
assert.Equal(t, "cmd_2", dag.Steps[1].Name)
assert.Equal(t, "cmd_3", dag.Steps[2].Name)
assert.Contains(t, dag.Steps[2].Depends, "cmd_1")
assert.Contains(t, dag.Steps[2].Depends, "cmd_2")
assert.Equal(t, "cmd_4", dag.Steps[3].Name)
assert.Contains(t, dag.Steps[3].Depends, "cmd_1")
assert.Contains(t, dag.Steps[3].Depends, "cmd_2")
})
}
func TestShorthandCommandSyntax(t *testing.T) {
t.Parallel()
t.Run("SimpleShorthandCommands", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- echo "hello"
- ls -la
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Len(t, dag.Steps, 2)
// First step
assert.Equal(t, "echo \"hello\"", dag.Steps[0].CmdWithArgs)
assert.Equal(t, "echo", dag.Steps[0].Command)
assert.Equal(t, []string{"hello"}, dag.Steps[0].Args)
assert.Equal(t, "cmd_1", dag.Steps[0].Name) // Auto-generated name
// Second step
assert.Equal(t, "ls -la", dag.Steps[1].CmdWithArgs)
assert.Equal(t, "ls", dag.Steps[1].Command)
assert.Equal(t, []string{"-la"}, dag.Steps[1].Args)
assert.Equal(t, "cmd_2", dag.Steps[1].Name) // Auto-generated name
})
t.Run("MixedShorthandAndStandardSyntax", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- echo "starting"
- name: build
command: make build
env:
DEBUG: "true"
- ls -la
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Len(t, dag.Steps, 3)
// First step (shorthand)
assert.Equal(t, "echo \"starting\"", dag.Steps[0].CmdWithArgs)
assert.Equal(t, "cmd_1", dag.Steps[0].Name)
// Second step (standard)
assert.Equal(t, "make build", dag.Steps[1].CmdWithArgs)
assert.Equal(t, "build", dag.Steps[1].Name)
assert.Contains(t, dag.Steps[1].Env, "DEBUG=true")
// Third step (shorthand)
assert.Equal(t, "ls -la", dag.Steps[2].CmdWithArgs)
assert.Equal(t, "cmd_3", dag.Steps[2].Name)
})
}
func TestOptionalStepNames(t *testing.T) {
t.Parallel()
t.Run("AutoGenerateNames", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- echo "hello"
- npm test
- go build
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
require.Len(t, th.Steps, 3)
assert.Equal(t, "cmd_1", th.Steps[0].Name)
assert.Equal(t, "cmd_2", th.Steps[1].Name)
assert.Equal(t, "cmd_3", th.Steps[2].Name)
})
t.Run("MixedExplicitAndGenerated", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- setup.sh
- name: build
command: make all
- command: test.sh
depends: build
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
require.Len(t, th.Steps, 3)
assert.Equal(t, "cmd_1", th.Steps[0].Name)
assert.Equal(t, "build", th.Steps[1].Name)
assert.Equal(t, "cmd_3", th.Steps[2].Name)
})
t.Run("HandleNameConflicts", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- echo "first"
- name: cmd_2
command: echo "explicit"
- echo "third"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
require.Len(t, th.Steps, 3)
assert.Equal(t, "cmd_1", th.Steps[0].Name)
assert.Equal(t, "cmd_2", th.Steps[1].Name)
assert.Equal(t, "cmd_3", th.Steps[2].Name)
})
t.Run("DependenciesWithGeneratedNames", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- git pull
- command: npm install
depends: cmd_1
- command: npm test
depends: cmd_2
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
require.Len(t, th.Steps, 3)
assert.Equal(t, "cmd_1", th.Steps[0].Name)
assert.Equal(t, "cmd_2", th.Steps[1].Name)
assert.Equal(t, "cmd_3", th.Steps[2].Name)
// Check dependencies are correctly resolved
assert.Equal(t, []string{"cmd_1"}, th.Steps[1].Depends)
assert.Equal(t, []string{"cmd_2"}, th.Steps[2].Depends)
})
t.Run("OutputVariablesWithGeneratedNames", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- command: echo "v1.0.0"
output: VERSION
- command: echo "Building version ${VERSION}"
depends: cmd_1
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
require.Len(t, th.Steps, 2)
assert.Equal(t, "cmd_1", th.Steps[0].Name)
assert.Equal(t, "cmd_2", th.Steps[1].Name)
assert.Equal(t, "VERSION", th.Steps[0].Output)
assert.Equal(t, []string{"cmd_1"}, th.Steps[1].Depends)
})
t.Run("TypeBasedNaming", func(t *testing.T) {
t.Parallel()
// Test different step types get appropriate names
data := []byte(`
steps:
- echo "command"
- script: |
echo "script content"
- executor:
type: http
config:
url: https://example.com
- call: sub-dag
- executor:
type: docker
config:
image: alpine
- executor:
type: ssh
config:
host: example.com
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
require.Len(t, th.Steps, 6)
assert.Equal(t, "cmd_1", th.Steps[0].Name)
assert.Equal(t, "script_2", th.Steps[1].Name)
assert.Equal(t, "http_3", th.Steps[2].Name)
assert.Equal(t, "dag_4", th.Steps[3].Name)
assert.Equal(t, "docker_5", th.Steps[4].Name)
assert.Equal(t, "ssh_6", th.Steps[5].Name)
})
t.Run("BackwardCompatibility", func(t *testing.T) {
t.Parallel()
// Ensure existing DAGs with explicit names still work
data := []byte(`
steps:
- echo "setup"
- echo "test"
- echo "deploy"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
th := DAG{t: t, DAG: dag}
require.Len(t, th.Steps, 3)
assert.Equal(t, "cmd_1", th.Steps[0].Name)
assert.Equal(t, "cmd_2", th.Steps[1].Name)
assert.Equal(t, "cmd_3", th.Steps[2].Name)
// In chain mode, sequential dependencies are implicit
assert.Equal(t, []string{"cmd_1"}, th.Steps[1].Depends)
assert.Equal(t, []string{"cmd_2"}, th.Steps[2].Depends)
})
}
func TestStepIDValidation(t *testing.T) {
t.Parallel()
t.Run("ValidID", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: valid_id
command: echo test
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Equal(t, "valid_id", dag.Steps[0].ID)
})
t.Run("InvalidIDFormat", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: 123invalid
command: echo test
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid step ID format")
})
t.Run("DuplicateIDs", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: myid
command: echo test1
- name: step2
id: myid
command: echo test2
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "duplicate step ID")
})
t.Run("IDConflictsWithStepName", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: step2
command: echo test1
- name: step2
command: echo test2
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "conflicts with another step's name")
})
t.Run("NameConflictsWithStepID", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: myid
command: echo test1
- name: myid
command: echo test2
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "conflicts with another step's name")
})
t.Run("ReservedWordID", func(t *testing.T) {
data := []byte(`
steps:
- name: step1
id: env
command: echo test
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "reserved word")
})
}
func TestStepIDInDependencies(t *testing.T) {
t.Parallel()
t.Run("DependOnStepByID", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: first
command: echo test1
- name: step2
depends: first
command: echo test2
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 2)
assert.Equal(t, "first", dag.Steps[0].ID)
assert.Equal(t, []string{"step1"}, dag.Steps[1].Depends) // ID "first" resolved to name "step1"
})
t.Run("DependOnStepByNameWhenIDExists", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: first
command: echo test1
- name: step2
depends: step1
command: echo test2
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 2)
assert.Equal(t, []string{"step1"}, dag.Steps[1].Depends)
})
t.Run("MultipleDependenciesWithIDs", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: step1
id: first
command: echo test1
- name: step2
id: second
command: echo test2
- name: step3
depends:
- first
- second
command: echo test3
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 3)
assert.Equal(t, []string{"step1", "step2"}, dag.Steps[2].Depends) // IDs resolved to names
})
t.Run("MixOfIDAndNameDependencies", func(t *testing.T) {
data := []byte(`
steps:
- name: step1
id: first
command: echo test1
- name: step2
command: echo test2
- name: step3
depends:
- first
- step2
command: echo test3
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 3)
assert.Equal(t, []string{"step1", "step2"}, dag.Steps[2].Depends) // ID "first" resolved to name "step1"
})
}
func TestChainTypeWithStepIDs(t *testing.T) {
t.Parallel()
data := []byte(`
type: chain
steps:
- name: step1
id: s1
command: echo first
- name: step2
id: s2
command: echo second
- name: step3
command: echo third
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 3)
// Verify IDs are preserved
assert.Equal(t, "s1", dag.Steps[0].ID)
assert.Equal(t, "s2", dag.Steps[1].ID)
assert.Equal(t, "", dag.Steps[2].ID)
// Verify chain dependencies were added
assert.Empty(t, dag.Steps[0].Depends)
assert.Equal(t, []string{"step1"}, dag.Steps[1].Depends)
assert.Equal(t, []string{"step2"}, dag.Steps[2].Depends)
}
func TestResolveStepDependencies(t *testing.T) {
t.Parallel()
tests := []struct {
name string
yaml string
expected map[string][]string // step name -> expected depends
}{
{
name: "SingleIDDependency",
yaml: `
steps:
- name: step-one
id: s1
command: echo "1"
- name: step-two
depends: s1
command: echo "2"
`,
expected: map[string][]string{
"step-two": {"step-one"},
},
},
{
name: "MultipleIDDependencies",
yaml: `
steps:
- name: step-one
id: s1
command: echo "1"
- name: step-two
id: s2
command: echo "2"
- name: step-three
depends:
- s1
- s2
command: echo "3"
`,
expected: map[string][]string{
"step-three": {"step-one", "step-two"},
},
},
{
name: "MixedIDAndNameDependencies",
yaml: `
steps:
- name: step-one
id: s1
command: echo "1"
- name: step-two
command: echo "2"
- name: step-three
depends:
- s1
- step-two
command: echo "3"
`,
expected: map[string][]string{
"step-three": {"step-one", "step-two"},
},
},
{
name: "NoIDDependencies",
yaml: `
steps:
- name: step-one
command: echo "1"
- name: step-two
depends: step-one
command: echo "2"
`,
expected: map[string][]string{
"step-two": {"step-one"},
},
},
{
name: "IDSameAsName",
yaml: `
steps:
- name: step-one
id: step-one
command: echo "1"
- name: step-two
depends: step-one
command: echo "2"
`,
expected: map[string][]string{
"step-two": {"step-one"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(tt.yaml))
require.NoError(t, err)
// Check that dependencies were resolved correctly
for _, step := range dag.Steps {
if expectedDeps, exists := tt.expected[step.Name]; exists {
assert.Equal(t, expectedDeps, step.Depends,
"Step %s dependencies should be resolved correctly", step.Name)
}
}
})
}
}
func TestResolveStepDependencies_Errors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
yaml string
expectedErr string
}{
{
name: "DependencyOnNonExistentID",
yaml: `
steps:
- name: step-one
command: echo "1"
- name: step-two
depends: nonexistent
command: echo "2"
`,
expectedErr: "", // This should be caught by dependency validation, not ID resolution
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
_, err := spec.LoadYAML(ctx, []byte(tt.yaml))
if tt.expectedErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedErr)
} else {
// Some tests expect no error from ID resolution
// but might fail in other validation steps
_ = err
}
})
}
}
func TestBuildOTel(t *testing.T) {
t.Parallel()
t.Run("BasicOTelConfig", func(t *testing.T) {
yaml := `
otel:
enabled: true
endpoint: localhost:4317
steps:
- name: step1
command: echo "test"
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.OTel)
assert.True(t, dag.OTel.Enabled)
assert.Equal(t, "localhost:4317", dag.OTel.Endpoint)
})
t.Run("FullOTelConfig", func(t *testing.T) {
yaml := `
otel:
enabled: true
endpoint: otel-collector:4317
headers:
Authorization: Bearer token
insecure: true
timeout: 30s
resource:
service.name: dagu-test
service.version: "1.0.0"
steps:
- name: step1
command: echo "test"
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.OTel)
assert.True(t, dag.OTel.Enabled)
assert.Equal(t, "otel-collector:4317", dag.OTel.Endpoint)
assert.Equal(t, "Bearer token", dag.OTel.Headers["Authorization"])
assert.True(t, dag.OTel.Insecure)
assert.Equal(t, 30*time.Second, dag.OTel.Timeout)
assert.Equal(t, "dagu-test", dag.OTel.Resource["service.name"])
assert.Equal(t, "1.0.0", dag.OTel.Resource["service.version"])
})
t.Run("DisabledOTel", func(t *testing.T) {
yaml := `
steps:
- name: step1
command: echo "test"
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
assert.Nil(t, dag.OTel)
})
}
func TestContainer(t *testing.T) {
t.Run("BasicContainer", func(t *testing.T) {
yaml := `
container:
image: python:3.11-slim
pullPolicy: always
steps:
- name: step1
command: python script.py
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Equal(t, "python:3.11-slim", dag.Container.Image)
assert.Equal(t, core.PullPolicyAlways, dag.Container.PullPolicy)
})
t.Run("ContainerWithName", func(t *testing.T) {
yaml := `
container:
name: my-dag-container
image: alpine:latest
steps:
- name: step1
command: echo hello
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Equal(t, "my-dag-container", dag.Container.Name)
assert.Equal(t, "alpine:latest", dag.Container.Image)
})
t.Run("ContainerNameEmpty", func(t *testing.T) {
yaml := `
container:
image: alpine:latest
steps:
- name: step1
command: echo hello
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Equal(t, "", dag.Container.Name)
})
t.Run("ContainerNameTrimmed", func(t *testing.T) {
yaml := `
container:
name: " my-container "
image: alpine:latest
steps:
- name: step1
command: echo hello
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Equal(t, "my-container", dag.Container.Name)
})
t.Run("ContainerWithAllFields", func(t *testing.T) {
yaml := `
container:
image: node:18-alpine
pullPolicy: missing
env:
- NODE_ENV: production
- API_KEY: secret123
volumes:
- /data:/data:ro
- /output:/output:rw
user: "1000:1000"
workingDir: /app
platform: linux/amd64
ports:
- "8080:8080"
- "9090:9090"
network: bridge
keepContainer: true
steps:
- name: step1
command: node app.js
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Equal(t, "node:18-alpine", dag.Container.Image)
assert.Equal(t, core.PullPolicyMissing, dag.Container.PullPolicy)
assert.Contains(t, dag.Container.Env, "NODE_ENV=production")
assert.Contains(t, dag.Container.Env, "API_KEY=secret123")
assert.Equal(t, []string{"/data:/data:ro", "/output:/output:rw"}, dag.Container.Volumes)
assert.Equal(t, "1000:1000", dag.Container.User)
assert.Equal(t, "/app", dag.Container.GetWorkingDir())
assert.Equal(t, "linux/amd64", dag.Container.Platform)
assert.Equal(t, []string{"8080:8080", "9090:9090"}, dag.Container.Ports)
assert.Equal(t, "bridge", dag.Container.Network)
assert.True(t, dag.Container.KeepContainer)
})
t.Run("ContainerEnvAsMap", func(t *testing.T) {
yaml := `
container:
image: alpine
env:
FOO: bar
BAZ: qux
steps:
- name: step1
command: echo test
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Contains(t, dag.Container.Env, "FOO=bar")
assert.Contains(t, dag.Container.Env, "BAZ=qux")
})
t.Run("ContainerPullPolicyVariations", func(t *testing.T) {
testCases := []struct {
name string
pullPolicy string
expected core.PullPolicy
}{
{"always", "always", core.PullPolicyAlways},
{"never", "never", core.PullPolicyNever},
{"missing", "missing", core.PullPolicyMissing},
{"true", "true", core.PullPolicyAlways},
{"false", "false", core.PullPolicyNever},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
yaml := `
container:
image: alpine
pullPolicy: ` + tc.pullPolicy + `
steps:
- name: step1
command: echo test
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Equal(t, tc.expected, dag.Container.PullPolicy)
})
}
})
t.Run("ContainerPullPolicyBoolean", func(t *testing.T) {
// Test with boolean true
yaml := `
container:
image: alpine
pullPolicy: true
steps:
- name: step1
command: echo test
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Equal(t, core.PullPolicyAlways, dag.Container.PullPolicy)
})
t.Run("ContainerWithoutPullPolicy", func(t *testing.T) {
yaml := `
container:
image: alpine
steps:
- name: step1
command: echo test
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.Container)
assert.Equal(t, core.PullPolicyMissing, dag.Container.PullPolicy)
})
t.Run("NoContainer", func(t *testing.T) {
yaml := `
steps:
- name: step1
command: echo test
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
assert.Nil(t, dag.Container)
})
t.Run("InvalidPullPolicy", func(t *testing.T) {
yaml := `
container:
image: alpine
pullPolicy: invalid_policy
steps:
- name: step1
command: echo test
`
ctx := context.Background()
_, err := spec.LoadYAML(ctx, []byte(yaml))
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse pull policy")
})
t.Run("ContainerWithoutImage", func(t *testing.T) {
yaml := `
container:
pullPolicy: always
env:
- FOO: bar
steps:
- name: step1
command: echo test
`
ctx := context.Background()
_, err := spec.LoadYAML(ctx, []byte(yaml))
assert.Error(t, err)
assert.Contains(t, err.Error(), "image is required when container is specified")
})
}
func TestContainerExecutorIntegration(t *testing.T) {
t.Run("StepInheritsContainerExecutor", func(t *testing.T) {
yaml := `
container:
image: python:3.11-slim
steps:
- name: step1
command: python script.py
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
// Step should have docker executor type when DAG has container
assert.Equal(t, "container", dag.Steps[0].ExecutorConfig.Type)
})
t.Run("ExplicitExecutorOverridesContainer", func(t *testing.T) {
yaml := `
container:
image: python:3.11-slim
steps:
- name: step1
command: echo test
executor: shell
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
// Explicit executor should override DAG-level container
assert.Equal(t, "shell", dag.Steps[0].ExecutorConfig.Type)
})
t.Run("NoContainerNoExecutor", func(t *testing.T) {
yaml := `
steps:
- name: step1
command: echo test
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
// No container and no executor means default (empty) executor
assert.Equal(t, "", dag.Steps[0].ExecutorConfig.Type)
})
t.Run("StepWithDockerExecutorConfig", func(t *testing.T) {
yaml := `
container:
image: node:18-alpine
steps:
- name: step1
command: node app.js
executor:
type: docker
config:
image: python:3.11
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
// Step-level docker config should override DAG container
assert.Equal(t, "docker", dag.Steps[0].ExecutorConfig.Type)
assert.Equal(t, "python:3.11", dag.Steps[0].ExecutorConfig.Config["image"])
})
t.Run("MultipleStepsWithContainer", func(t *testing.T) {
yaml := `
container:
image: alpine:latest
steps:
- name: step1
command: echo "step 1"
- name: step2
command: echo "step 2"
executor: shell
- name: step3
command: echo "step 3"
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 3)
// Step 1 and 3 should inherit docker executor
assert.Equal(t, "container", dag.Steps[0].ExecutorConfig.Type)
assert.Equal(t, "shell", dag.Steps[1].ExecutorConfig.Type)
assert.Equal(t, "container", dag.Steps[2].ExecutorConfig.Type)
})
}
func TestSSHConfiguration(t *testing.T) {
t.Run("BasicSSHConfig", func(t *testing.T) {
yaml := `
ssh:
user: testuser
host: example.com
port: 2222
key: ~/.ssh/id_rsa
steps:
- name: step1
command: echo hello
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.SSH)
assert.Equal(t, "testuser", dag.SSH.User)
assert.Equal(t, "example.com", dag.SSH.Host)
assert.Equal(t, "2222", dag.SSH.Port)
assert.Equal(t, "~/.ssh/id_rsa", dag.SSH.Key)
})
t.Run("SSHConfigWithStrictHostKey", func(t *testing.T) {
yaml := `
ssh:
user: testuser
host: example.com
strictHostKey: false
knownHostFile: ~/.ssh/custom_known_hosts
steps:
- name: step1
command: echo hello
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.SSH)
assert.Equal(t, "testuser", dag.SSH.User)
assert.Equal(t, "example.com", dag.SSH.Host)
assert.Equal(t, "22", dag.SSH.Port) // Default port
assert.False(t, dag.SSH.StrictHostKey)
assert.Equal(t, "~/.ssh/custom_known_hosts", dag.SSH.KnownHostFile)
})
t.Run("SSHConfigDefaultValues", func(t *testing.T) {
yaml := `
ssh:
user: testuser
host: example.com
steps:
- name: step1
command: echo hello
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag.SSH)
assert.Equal(t, "22", dag.SSH.Port) // Should default to 22
assert.True(t, dag.SSH.StrictHostKey) // Should default to true for security
assert.Equal(t, "", dag.SSH.KnownHostFile) // Empty, will use default ~/.ssh/known_hosts at runtime
})
}
func TestSSHInheritance(t *testing.T) {
t.Run("StepInheritsSSHFromDAG", func(t *testing.T) {
yaml := `
ssh:
user: testuser
host: example.com
key: ~/.ssh/id_rsa
steps:
- name: step1
command: echo hello
- name: step2
command: ls -la
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 2)
// Both steps should inherit SSH executor
for _, step := range dag.Steps {
assert.Equal(t, "ssh", step.ExecutorConfig.Type)
}
})
t.Run("StepOverridesSSHConfig", func(t *testing.T) {
yaml := `
ssh:
user: defaultuser
host: default.com
key: ~/.ssh/default_key
steps:
- name: step1
command: echo hello
executor:
type: ssh
config:
user: overrideuser
ip: override.com
- name: step2
command: echo world
executor:
type: command
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 2)
// Step 1 should have overridden values
step1 := dag.Steps[0]
assert.Equal(t, "ssh", step1.ExecutorConfig.Type)
// Step 2 should use command executor
step2 := dag.Steps[1]
assert.Equal(t, "command", step2.ExecutorConfig.Type)
})
}
func TestStepLevelEnv(t *testing.T) {
t.Run("BasicStepEnv", func(t *testing.T) {
yaml := `
steps:
- name: step1
command: echo $STEP_VAR
env:
- STEP_VAR: step_value
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Equal(t, []string{"STEP_VAR=step_value"}, dag.Steps[0].Env)
})
t.Run("StepEnvOverridesDAGEnv", func(t *testing.T) {
yaml := `
env:
- SHARED_VAR: dag_value
- DAG_ONLY: dag_only_value
steps:
- name: step1
command: echo $SHARED_VAR
env:
- SHARED_VAR: step_value
- STEP_ONLY: step_only_value
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
// Check DAG-level env
assert.Contains(t, dag.Env, "SHARED_VAR=dag_value")
assert.Contains(t, dag.Env, "DAG_ONLY=dag_only_value")
// Check step-level env
assert.Contains(t, dag.Steps[0].Env, "SHARED_VAR=step_value")
assert.Contains(t, dag.Steps[0].Env, "STEP_ONLY=step_only_value")
})
t.Run("StepEnvAsMap", func(t *testing.T) {
yaml := `
steps:
- name: step1
command: echo test
env:
FOO: foo_value
BAR: bar_value
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Contains(t, dag.Steps[0].Env, "FOO=foo_value")
assert.Contains(t, dag.Steps[0].Env, "BAR=bar_value")
})
t.Run("StepEnvWithSubstitution", func(t *testing.T) {
yaml := `
env:
- BASE_PATH: /tmp
steps:
- name: step1
command: echo $FULL_PATH
env:
- FULL_PATH: ${BASE_PATH}/data
- COMPUTED: "` + "`echo computed_value`" + `"
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Contains(t, dag.Steps[0].Env, "FULL_PATH=${BASE_PATH}/data")
assert.Contains(t, dag.Steps[0].Env, "COMPUTED=`echo computed_value`")
})
t.Run("MultipleStepsWithDifferentEnvs", func(t *testing.T) {
yaml := `
steps:
- name: step1
command: echo $ENV_VAR
env:
- ENV_VAR: value1
- name: step2
command: echo $ENV_VAR
env:
- ENV_VAR: value2
- name: step3
command: echo $ENV_VAR
# No env, should inherit DAG env only
`
ctx := context.Background()
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 3)
assert.Equal(t, []string{"ENV_VAR=value1"}, dag.Steps[0].Env)
assert.Equal(t, []string{"ENV_VAR=value2"}, dag.Steps[1].Env)
assert.Empty(t, dag.Steps[2].Env)
})
t.Run("StepEnvComplexValues", func(t *testing.T) {
yaml := `
steps:
- name: step1
command: echo test
env:
- PATH: "/custom/bin:${PATH}"
- JSON_CONFIG: '{"key": "value", "nested": {"foo": "bar"}}'
- MULTI_LINE: |
line1
line2
`
ctx := context.Background()
// Set PATH env var for substitution test
origPath := os.Getenv("PATH")
defer func() { os.Setenv("PATH", origPath) }()
os.Setenv("PATH", "/usr/bin")
dag, err := spec.LoadYAML(ctx, []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Contains(t, dag.Steps[0].Env, "PATH=/custom/bin:${PATH}")
assert.Contains(t, dag.Steps[0].Env, `JSON_CONFIG={"key": "value", "nested": {"foo": "bar"}}`)
assert.Contains(t, dag.Steps[0].Env, "MULTI_LINE=line1\nline2\n")
})
}
func TestBuildRegistryAuths(t *testing.T) {
t.Run("ParseRegistryAuthsFromYAML", func(t *testing.T) {
yaml := `
registryAuths:
docker.io:
username: docker-user
password: docker-pass
ghcr.io:
username: github-user
password: github-token
gcr.io:
auth: Z2NyLXVzZXI6Z2NyLXBhc3M= # base64("gcr-user:gcr-pass")
container:
image: docker.io/myapp:latest
steps:
- echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
// Check that registryAuths were parsed correctly
assert.NotNil(t, dag.RegistryAuths)
assert.Len(t, dag.RegistryAuths, 3)
// Check docker.io auth
dockerAuth, exists := dag.RegistryAuths["docker.io"]
assert.True(t, exists)
assert.Equal(t, "docker-user", dockerAuth.Username)
assert.Equal(t, "docker-pass", dockerAuth.Password)
// Check ghcr.io auth
ghcrAuth, exists := dag.RegistryAuths["ghcr.io"]
assert.True(t, exists)
assert.Equal(t, "github-user", ghcrAuth.Username)
assert.Equal(t, "github-token", ghcrAuth.Password)
// Check gcr.io auth (with pre-encoded auth field)
gcrAuth, exists := dag.RegistryAuths["gcr.io"]
assert.True(t, exists)
assert.Equal(t, "Z2NyLXVzZXI6Z2NyLXBhc3M=", gcrAuth.Auth)
assert.Empty(t, gcrAuth.Username) // Should be empty when auth is provided
assert.Empty(t, gcrAuth.Password) // Should be empty when auth is provided
})
t.Run("EmptyRegistryAuths", func(t *testing.T) {
yaml := `
steps:
- echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
// Should be nil when not specified
assert.Nil(t, dag.RegistryAuths)
})
t.Run("RegistryAuthsWithEnvironmentVariables", func(t *testing.T) {
// Set environment variables for testing
t.Setenv("DOCKER_USER", "env-docker-user")
t.Setenv("DOCKER_PASS", "env-docker-pass")
yaml := `
registryAuths:
docker.io:
username: ${DOCKER_USER}
password: ${DOCKER_PASS}
steps:
- echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
// Check that environment variables were expanded
dockerAuth, exists := dag.RegistryAuths["docker.io"]
assert.True(t, exists)
assert.Equal(t, "env-docker-user", dockerAuth.Username)
assert.Equal(t, "env-docker-pass", dockerAuth.Password)
})
t.Run("RegistryAuthsAsJSONString", func(t *testing.T) {
// Simulate DOCKER_AUTH_CONFIG style JSON string
jsonAuth := `{"docker.io": {"username": "json-user", "password": "json-pass"}}`
t.Setenv("DOCKER_AUTH_JSON", jsonAuth)
yaml := `
registryAuths: ${DOCKER_AUTH_JSON}
steps:
- echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
// Should have stored the JSON string as _json entry
assert.NotNil(t, dag.RegistryAuths)
jsonEntry, exists := dag.RegistryAuths["_json"]
assert.True(t, exists)
assert.Equal(t, jsonAuth, jsonEntry.Auth)
})
t.Run("RegistryAuthsWithStringValuesPerRegistry", func(t *testing.T) {
yaml := `
registryAuths:
docker.io: '{"username": "user1", "password": "pass1"}'
ghcr.io: '{"username": "user2", "password": "pass2"}'
steps:
- echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
// Check docker.io - should have the JSON string in Auth field
dockerAuth, exists := dag.RegistryAuths["docker.io"]
assert.True(t, exists)
assert.Equal(t, `{"username": "user1", "password": "pass1"}`, dockerAuth.Auth)
assert.Empty(t, dockerAuth.Username)
assert.Empty(t, dockerAuth.Password)
// Check ghcr.io
ghcrAuth, exists := dag.RegistryAuths["ghcr.io"]
assert.True(t, exists)
assert.Equal(t, `{"username": "user2", "password": "pass2"}`, ghcrAuth.Auth)
})
}
func TestBuildWorkingDir(t *testing.T) {
t.Run("ExplicitAbsoluteWorkingDir", func(t *testing.T) {
tempDir := t.TempDir()
yaml := fmt.Sprintf(`
workingDir: %s
steps:
- echo hello
`, tempDir)
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
assert.Equal(t, tempDir, dag.WorkingDir)
})
t.Run("WorkingDirWithEnvVarExpansion", func(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TEST_DIR", tempDir)
yaml := `
workingDir: ${TEST_DIR}/subdir
steps:
- echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
assert.Equal(t, filepath.Join(tempDir, "subdir"), dag.WorkingDir)
})
t.Run("DefaultWorkingDirWhenNoFile", func(t *testing.T) {
yaml := `
steps:
- echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
// Should be current working directory when loaded from YAML (no file)
expectedDir, err := os.Getwd()
require.NoError(t, err)
assert.Equal(t, expectedDir, dag.WorkingDir)
})
t.Run("RelativeWorkingDirResolvesAgainstDAGFile", func(t *testing.T) {
// Create a temp directory with a DAG file
tempDir := t.TempDir()
dagFile := filepath.Join(tempDir, "dag.yaml")
subDir := filepath.Join(tempDir, "scripts")
yaml := `
workingDir: ./scripts
steps:
- echo hello
`
require.NoError(t, os.WriteFile(dagFile, []byte(yaml), 0644))
dag, err := spec.Load(context.Background(), dagFile)
require.NoError(t, err)
require.NotNil(t, dag)
// Relative path should resolve against DAG file directory
assert.Equal(t, subDir, dag.WorkingDir)
})
t.Run("RelativeWorkingDirWithoutDAGFile_ResolvesAgainstCWD", func(t *testing.T) {
yaml := `
workingDir: ./subdir
steps:
- echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.NotNil(t, dag)
// When no DAG file, relative path is resolved via fileutil.ResolvePathOrBlank
// which uses filepath.Abs (resolves against CWD)
cwd, err := os.Getwd()
require.NoError(t, err)
expectedDir := filepath.Join(cwd, "subdir")
assert.Equal(t, expectedDir, dag.WorkingDir)
})
}
func TestBuildStepWorkingDir(t *testing.T) {
t.Run("StepWithDirField", func(t *testing.T) {
yaml := `
steps:
- name: step1
dir: /tmp/mydir
command: echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Equal(t, "/tmp/mydir", dag.Steps[0].Dir)
})
t.Run("StepWithWorkingDirField", func(t *testing.T) {
yaml := `
steps:
- name: step1
workingDir: /tmp/myworkdir
command: echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Equal(t, "/tmp/myworkdir", dag.Steps[0].Dir)
})
t.Run("StepWorkingDirTakesPrecedenceOverDir", func(t *testing.T) {
yaml := `
steps:
- name: step1
dir: /tmp/dir
workingDir: /tmp/workingdir
command: echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
// workingDir should take precedence over dir
assert.Equal(t, "/tmp/workingdir", dag.Steps[0].Dir)
})
t.Run("StepWithRelativeDir", func(t *testing.T) {
yaml := `
steps:
- name: step1
dir: ./scripts
command: echo hello
`
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
// At build time, relative dir is stored as-is (resolved at runtime)
assert.Equal(t, "./scripts", dag.Steps[0].Dir)
})
}
func TestDAGLoadEnv(t *testing.T) {
t.Run("LoadEnvWithDotenvAndEnvVars", func(t *testing.T) {
// Create a temp directory with a .env file
tempDir := t.TempDir()
envFile := filepath.Join(tempDir, ".env")
envContent := "LOAD_ENV_DOTENV_VAR=from_file\n"
err := os.WriteFile(envFile, []byte(envContent), 0644)
require.NoError(t, err)
yaml := fmt.Sprintf(`
workingDir: %s
dotenv: .env
env:
- LOAD_ENV_ENV_VAR: from_dag
- LOAD_ENV_ANOTHER_VAR: another_value
steps:
- echo hello
`, tempDir)
dag, err := spec.LoadYAMLWithOpts(context.Background(), []byte(yaml), spec.BuildOpts{Flags: spec.BuildFlagNoEval})
require.NoError(t, err)
require.NotNil(t, dag)
// Load environment variables from dotenv file
dag.LoadDotEnv(context.Background())
// Verify environment variables are in dag.Env (not process env)
// Child processes will receive them via cmd.Env = AllEnvs()
envMap := make(map[string]string)
for _, env := range dag.Env {
key, value, found := strings.Cut(env, "=")
if found {
envMap[key] = value
}
}
assert.Equal(t, "from_file", envMap["LOAD_ENV_DOTENV_VAR"])
assert.Equal(t, "from_dag", envMap["LOAD_ENV_ENV_VAR"])
assert.Equal(t, "another_value", envMap["LOAD_ENV_ANOTHER_VAR"])
})
t.Run("LoadEnvWithMissingDotenvFile", func(t *testing.T) {
yaml := `
dotenv: nonexistent.env
env:
- TEST_VAR_LOAD_ENV: test_value
steps:
- echo hello
`
dag, err := spec.LoadYAMLWithOpts(context.Background(), []byte(yaml), spec.BuildOpts{Flags: spec.BuildFlagNoEval})
require.NoError(t, err)
require.NotNil(t, dag)
// LoadDotEnv should not fail even if dotenv file doesn't exist
dag.LoadDotEnv(context.Background())
// Environment variables from env should still be in dag.Env
envMap := make(map[string]string)
for _, env := range dag.Env {
key, value, found := strings.Cut(env, "=")
if found {
envMap[key] = value
}
}
assert.Equal(t, "test_value", envMap["TEST_VAR_LOAD_ENV"])
})
}
func TestSecrets(t *testing.T) {
t.Run("ValidSecretsArray", func(t *testing.T) {
data := []byte(`
secrets:
- name: DB_PASSWORD
provider: gcp-secrets
key: secret/data/prod/db
options:
namespace: production
- name: API_KEY
provider: env
key: API_KEY
steps:
- name: test
command: echo "test"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Secrets, 2)
// Verify first secret
assert.Equal(t, "DB_PASSWORD", dag.Secrets[0].Name)
assert.Equal(t, "gcp-secrets", dag.Secrets[0].Provider)
assert.Equal(t, "secret/data/prod/db", dag.Secrets[0].Key)
assert.Equal(t, "production", dag.Secrets[0].Options["namespace"])
// Verify second secret
assert.Equal(t, "API_KEY", dag.Secrets[1].Name)
assert.Equal(t, "env", dag.Secrets[1].Provider)
assert.Equal(t, "API_KEY", dag.Secrets[1].Key)
assert.Empty(t, dag.Secrets[1].Options)
})
t.Run("MinimalValidSecret", func(t *testing.T) {
data := []byte(`
secrets:
- name: MY_SECRET
provider: env
key: MY_SECRET
steps:
- name: test
command: echo "test"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Secrets, 1)
assert.Equal(t, "MY_SECRET", dag.Secrets[0].Name)
assert.Equal(t, "env", dag.Secrets[0].Provider)
assert.Equal(t, "MY_SECRET", dag.Secrets[0].Key)
})
t.Run("MissingNameField", func(t *testing.T) {
data := []byte(`
secrets:
- provider: vault
key: secret/data/test
steps:
- name: test
command: echo "test"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "'name' field is required")
})
t.Run("MissingProviderField", func(t *testing.T) {
data := []byte(`
secrets:
- name: MY_SECRET
key: secret/data/test
steps:
- name: test
command: echo "test"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "'provider' field is required")
})
t.Run("MissingKeyField", func(t *testing.T) {
data := []byte(`
secrets:
- name: MY_SECRET
provider: vault
steps:
- name: test
command: echo "test"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "'key' field is required")
})
t.Run("DuplicateSecretNames", func(t *testing.T) {
data := []byte(`
secrets:
- name: API_KEY
provider: vault
key: secret/v1
- name: API_KEY
provider: env
key: API_KEY
steps:
- name: test
command: echo "test"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "duplicate secret name")
assert.Contains(t, err.Error(), "API_KEY")
})
t.Run("InvalidSecretsType", func(t *testing.T) {
data := []byte(`
secrets: "invalid string"
steps:
- name: test
command: echo "test"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
// YAML unmarshaler validates type before our code runs
assert.Contains(t, err.Error(), "Secrets")
assert.Contains(t, err.Error(), "array or slice")
})
t.Run("InvalidSecretItemType", func(t *testing.T) {
data := []byte(`
secrets:
- "invalid string item"
steps:
- name: test
command: echo "test"
`)
_, err := spec.LoadYAML(context.Background(), data)
require.Error(t, err)
// YAML unmarshaler validates type before our code runs
assert.Contains(t, err.Error(), "Secrets")
assert.Contains(t, err.Error(), "map or struct")
})
t.Run("EmptySecretsArray", func(t *testing.T) {
data := []byte(`
secrets: []
steps:
- name: test
command: echo "test"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Empty(t, dag.Secrets)
})
t.Run("NoSecretsField", func(t *testing.T) {
data := []byte(`
steps:
- name: test
command: echo "test"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Nil(t, dag.Secrets)
})
t.Run("ComplexProviderOptions", func(t *testing.T) {
data := []byte(`
secrets:
- name: DB_PASSWORD
provider: gcp-secrets
key: projects/my-project/secrets/db-password/versions/latest
options:
projectId: my-project
timeout: "30s"
retries: "3"
steps:
- name: test
command: echo "test"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Secrets, 1)
assert.Equal(t, "DB_PASSWORD", dag.Secrets[0].Name)
assert.Equal(t, "gcp-secrets", dag.Secrets[0].Provider)
assert.Equal(t, "projects/my-project/secrets/db-password/versions/latest", dag.Secrets[0].Key)
assert.Equal(t, "my-project", dag.Secrets[0].Options["projectId"])
assert.Equal(t, "30s", dag.Secrets[0].Options["timeout"])
assert.Equal(t, "3", dag.Secrets[0].Options["retries"])
})
}
func TestBuildStepParams(t *testing.T) {
t.Parallel()
t.Run("ParamsAsMap", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
command: actions/checkout@v4
executor:
type: github_action
params:
repository: myorg/myrepo
ref: main
token: secret123
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
step := dag.Steps[0]
params, err := step.Params.AsStringMap()
require.NoError(t, err)
assert.Equal(t, "myorg/myrepo", params["repository"])
assert.Equal(t, "main", params["ref"])
assert.Equal(t, "secret123", params["token"])
})
t.Run("ParamsAsString", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
command: actions/setup-go@v5
executor:
type: github_action
params: go-version=1.21 cache=true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
step := dag.Steps[0]
params, err := step.Params.AsStringMap()
require.NoError(t, err)
assert.Equal(t, "1.21", params["go-version"])
assert.Equal(t, "true", params["cache"])
})
t.Run("ParamsWithNumbers", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
command: some-action
params:
timeout: 300
retries: 3
enabled: true
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
step := dag.Steps[0]
params, err := step.Params.AsStringMap()
require.NoError(t, err)
assert.Equal(t, "300", params["timeout"])
assert.Equal(t, "3", params["retries"])
assert.Equal(t, "true", params["enabled"])
})
t.Run("NoParams", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
command: echo hello
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
step := dag.Steps[0]
assert.True(t, step.Params.IsEmpty())
})
t.Run("EmptyParams", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
command: echo hello
params: {}
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
step := dag.Steps[0]
params, err := step.Params.AsStringMap()
require.NoError(t, err)
assert.Empty(t, params)
})
t.Run("ParamsWithQuotedValues", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
command: some-action
params: message="hello world" count="42"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
step := dag.Steps[0]
params, err := step.Params.AsStringMap()
require.NoError(t, err)
assert.Equal(t, "hello world", params["message"])
assert.Equal(t, "42", params["count"])
})
}
func TestBuildShell(t *testing.T) {
t.Run("ShellAsSimpleString", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: bash
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Empty(t, dag.ShellArgs)
})
t.Run("ShellAsStringWithArgs", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: bash -e
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Equal(t, []string{"-e"}, dag.ShellArgs)
})
t.Run("ShellAsStringWithMultipleArgs", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: bash -e -u -o pipefail
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Equal(t, []string{"-e", "-u", "-o", "pipefail"}, dag.ShellArgs)
})
t.Run("ShellAsArray", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell:
- bash
- -e
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Equal(t, []string{"-e"}, dag.ShellArgs)
})
t.Run("ShellAsArrayWithMultipleArgs", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell:
- bash
- -e
- -u
- -o
- pipefail
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Equal(t, []string{"-e", "-u", "-o", "pipefail"}, dag.ShellArgs)
})
t.Run("ShellWithEnvVar", func(t *testing.T) {
t.Setenv("MY_SHELL", "/bin/zsh")
data := []byte(`
shell: $MY_SHELL
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "/bin/zsh", dag.Shell)
assert.Empty(t, dag.ShellArgs)
})
t.Run("ShellArrayWithEnvVar", func(t *testing.T) {
t.Setenv("SHELL_ARG", "-x")
data := []byte(`
shell:
- bash
- $SHELL_ARG
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Equal(t, []string{"-x"}, dag.ShellArgs)
})
t.Run("ShellNotSpecified", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
// Should have default shell from cmdutil.GetShellCommand("")
assert.NotEmpty(t, dag.Shell)
})
t.Run("ShellEmptyString", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: ""
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
// Should have default shell from cmdutil.GetShellCommand("")
assert.NotEmpty(t, dag.Shell)
})
t.Run("ShellEmptyArray", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: []
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
// Should have default shell from cmdutil.GetShellCommand("")
assert.NotEmpty(t, dag.Shell)
})
t.Run("ShellPwsh", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: pwsh
steps:
- "Write-Output hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "pwsh", dag.Shell)
assert.Empty(t, dag.ShellArgs)
})
t.Run("ShellPwshWithArgs", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: pwsh -NoProfile -NonInteractive
steps:
- "Write-Output hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "pwsh", dag.Shell)
assert.Equal(t, []string{"-NoProfile", "-NonInteractive"}, dag.ShellArgs)
})
t.Run("ShellNoEvalPreservesRaw", func(t *testing.T) {
t.Setenv("MY_SHELL", "/bin/zsh")
data := []byte(`
shell: $MY_SHELL -e
steps:
- "echo hello"
`)
dag, err := spec.LoadYAMLWithOpts(context.Background(), data, spec.BuildOpts{Flags: spec.BuildFlagNoEval})
require.NoError(t, err)
assert.Equal(t, "$MY_SHELL", dag.Shell)
assert.Equal(t, []string{"-e"}, dag.ShellArgs)
})
t.Run("ShellArrayNoEvalPreservesRaw", func(t *testing.T) {
t.Setenv("SHELL_ARG", "-x")
data := []byte(`
shell:
- bash
- $SHELL_ARG
steps:
- "echo hello"
`)
dag, err := spec.LoadYAMLWithOpts(context.Background(), data, spec.BuildOpts{Flags: spec.BuildFlagNoEval})
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Equal(t, []string{"$SHELL_ARG"}, dag.ShellArgs)
})
t.Run("ShellWithQuotedArgs", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: bash -c "set -e"
steps:
- "echo hello"
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Equal(t, []string{"-c", "set -e"}, dag.ShellArgs)
})
}
func TestBuildStepShell(t *testing.T) {
t.Run("StepShellAsSimpleString", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
shell: zsh
command: echo hello
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Equal(t, "zsh", dag.Steps[0].Shell)
assert.Empty(t, dag.Steps[0].ShellArgs)
})
t.Run("StepShellAsStringWithArgs", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
shell: bash -e -u
command: echo hello
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Equal(t, "bash", dag.Steps[0].Shell)
assert.Equal(t, []string{"-e", "-u"}, dag.Steps[0].ShellArgs)
})
t.Run("StepShellAsArray", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
shell:
- bash
- -e
- -o
- pipefail
command: echo hello
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
assert.Equal(t, "bash", dag.Steps[0].Shell)
assert.Equal(t, []string{"-e", "-o", "pipefail"}, dag.Steps[0].ShellArgs)
})
t.Run("StepShellOverridesDAGShell", func(t *testing.T) {
t.Parallel()
data := []byte(`
shell: bash -e
steps:
- name: test
shell: zsh
command: echo hello
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
assert.Equal(t, "bash", dag.Shell)
assert.Equal(t, []string{"-e"}, dag.ShellArgs)
require.Len(t, dag.Steps, 1)
assert.Equal(t, "zsh", dag.Steps[0].Shell)
assert.Empty(t, dag.Steps[0].ShellArgs)
})
t.Run("StepShellNotSpecified", func(t *testing.T) {
t.Parallel()
data := []byte(`
steps:
- name: test
command: echo hello
`)
dag, err := spec.LoadYAML(context.Background(), data)
require.NoError(t, err)
require.Len(t, dag.Steps, 1)
// Step shell should be empty when not specified (DAG shell is used at runtime)
assert.Empty(t, dag.Steps[0].Shell)
})
}