package workflow
import (
"strconv"
"strings"
"testing"
"time"
"github.com/robfig/cron/v3"
"github.com/stretchr/testify/assert"
)
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
yamlData := `
when:
- event: ["push", "pull_request"]
branch: ["main", "develop"]`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err, "YAML should unmarshal without error")
assert.Len(t, wf.When, 1, "Should have one constraint")
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)
assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
}
func TestAudienceIsValidatedAtParseTime(t *testing.T) {
tests := []struct {
name string
audience string
ok bool
}{
{"gcp provider resource", "//iam.googleapis.com/projects/tangled/locations/global/workloadIdentityPools/ci/providers/spindle", true},
{"https audience", "https://vault.example.com", true},
{"sigstore", "sigstore", true},
{"empty is unset", "", true},
{"space inside", "https://vault .example.com", false},
{"a newline", "https://vault.example.com\n", false},
{"nbsp", "https://vault\u00a0.example.com", false},
{"zero width space", "sig\u200bstore", false},
{"rtl override", "sig\u202estore", false},
{"line separator", "https://vault\u2028.example.com", false},
{"c1 nel", "sig\u0085store", false},
{"non-ascii letters", "sigst\u00f6re", false},
{"invalid utf-8", "sigst\xff\xfdo", false},
{"over 150 characters", strings.Repeat("a", 151), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
yamlData := "engine: nixery\naudience: " + strconv.Quote(tt.audience)
wf, err := FromFile("test.yml", []byte(yamlData))
if tt.ok {
assert.NoError(t, err)
assert.Equal(t, tt.audience, wf.Audience)
} else {
assert.Error(t, err)
}
})
}
}
func TestAudienceMustBeAScalar(t *testing.T) {
yamlData := "engine: nixery\naudience: [a, b]"
_, err := FromFile("test.yml", []byte(yamlData))
assert.Error(t, err, "a list audience must not parse")
}
func TestUnmarshalWorkflowWithRunsOnLabels(t *testing.T) {
yamlData := `
engine: microvm
runs_on: [linux/arm64, kvm]
when:
- event: push`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err)
assert.Equal(t, []string{"linux/arm64", "kvm"}, wf.RunsOn)
}
func TestUnmarshalCloneFalse(t *testing.T) {
yamlData := `
when:
- event: pull_request_close
clone:
skip: true
`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err)
assert.ElementsMatch(t, []string{"pull_request_close"}, wf.When[0].Event)
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
}
func TestUnmarshalWorkflowWithTags(t *testing.T) {
yamlData := `
when:
- event: ["push"]
tag: ["v*", "release-*"]`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err, "YAML should unmarshal without error")
assert.Len(t, wf.When, 1, "Should have one constraint")
assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
}
func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
yamlData := `
when:
- event: ["push"]
branch: ["main", "develop"]
tag: ["v*"]`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err, "YAML should unmarshal without error")
assert.Len(t, wf.When, 1, "Should have one constraint")
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
}
func TestMatchesPattern(t *testing.T) {
tests := []struct {
name string
input string
patterns []string
expected bool
}{
{"exact match", "main", []string{"main"}, true},
{"exact match in list", "develop", []string{"main", "develop"}, true},
{"no match", "feature", []string{"main", "develop"}, false},
{"wildcard prefix", "v1.0.0", []string{"v*"}, true},
{"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
{"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
{"double star prefix", "release-1.0.0", []string{"release-**"}, true},
{"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
{"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
{"double star no match", "feature/test", []string{"release/**"}, false},
{"no patterns matches nothing", "anything", []string{}, false},
{"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
{"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
{"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := matchesPattern(tt.input, tt.patterns)
assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
})
}
}
func TestConstraintMatchRef_Branches(t *testing.T) {
tests := []struct {
name string
constraint Constraint
ref string
expected bool
}{
{
name: "exact branch match",
constraint: Constraint{Branch: []string{"main"}},
ref: "refs/heads/main",
expected: true,
},
{
name: "branch glob match",
constraint: Constraint{Branch: []string{"feature-*"}},
ref: "refs/heads/feature-123",
expected: true,
},
{
name: "branch no match",
constraint: Constraint{Branch: []string{"main"}},
ref: "refs/heads/develop",
expected: false,
},
{
name: "no constraints matches nothing",
constraint: Constraint{},
ref: "refs/heads/anything",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := tt.constraint.MatchRef(tt.ref)
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
})
}
}
func TestConstraintMatchRef_Tags(t *testing.T) {
tests := []struct {
name string
constraint Constraint
ref string
expected bool
}{
{
name: "exact tag match",
constraint: Constraint{Tag: []string{"v1.0.0"}},
ref: "refs/tags/v1.0.0",
expected: true,
},
{
name: "tag glob match",
constraint: Constraint{Tag: []string{"v*"}},
ref: "refs/tags/v1.2.3",
expected: true,
},
{
name: "tag glob with pattern",
constraint: Constraint{Tag: []string{"release-*"}},
ref: "refs/tags/release-2024",
expected: true,
},
{
name: "tag no match",
constraint: Constraint{Tag: []string{"v*"}},
ref: "refs/tags/release-1.0",
expected: false,
},
{
name: "tag not matched when only branch constraint",
constraint: Constraint{Branch: []string{"main"}},
ref: "refs/tags/v1.0.0",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := tt.constraint.MatchRef(tt.ref)
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
})
}
}
func TestConstraintMatchRef_Combined(t *testing.T) {
tests := []struct {
name string
constraint Constraint
ref string
expected bool
}{
{
name: "matches branch in combined constraint",
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
ref: "refs/heads/main",
expected: true,
},
{
name: "matches tag in combined constraint",
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
ref: "refs/tags/v1.0.0",
expected: true,
},
{
name: "no match in combined constraint",
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
ref: "refs/heads/develop",
expected: false,
},
{
name: "glob patterns in combined constraint - branch",
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
ref: "refs/heads/release-2024",
expected: true,
},
{
name: "glob patterns in combined constraint - tag",
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
ref: "refs/tags/v2.0.0",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := tt.constraint.MatchRef(tt.ref)
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
})
}
}
func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
tests := []struct {
name string
constraint Constraint
branch string
expected bool
}{
{
name: "exact match",
constraint: Constraint{Branch: []string{"main"}},
branch: "main",
expected: true,
},
{
name: "glob match",
constraint: Constraint{Branch: []string{"feature-*"}},
branch: "feature-123",
expected: true,
},
{
name: "no match",
constraint: Constraint{Branch: []string{"main"}},
branch: "develop",
expected: false,
},
{
name: "multiple patterns with match",
constraint: Constraint{Branch: []string{"main", "release-*"}},
branch: "release-1.0",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := tt.constraint.MatchBranch(tt.branch)
assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
})
}
}
func TestMatchesAnyFile(t *testing.T) {
tests := []struct {
name string
files []string
patterns []string
expected bool
}{
{
name: "exact file match",
files: []string{"src/main.go"},
patterns: []string{"src/main.go"},
expected: true,
},
{
name: "glob match single star",
files: []string{"src/main.go"},
patterns: []string{"src/*.go"},
expected: true,
},
{
name: "glob match double star",
files: []string{"src/pkg/util.go"},
patterns: []string{"src/**/*.go"},
expected: true,
},
{
name: "any file in list matches",
files: []string{"README.md", "src/main.go", "docs/guide.md"},
patterns: []string{"src/**"},
expected: true,
},
{
name: "no file matches",
files: []string{"README.md", "docs/guide.md"},
patterns: []string{"src/**"},
expected: false,
},
{
name: "empty files list",
files: []string{},
patterns: []string{"src/**"},
expected: false,
},
{
name: "nil files list",
files: nil,
patterns: []string{"src/**"},
expected: false,
},
{
name: "multiple patterns, second matches",
files: []string{"docs/guide.md"},
patterns: []string{"src/**", "docs/**"},
expected: true,
},
{
name: "single star does not cross directory boundary",
files: []string{"src/pkg/util.go"},
patterns: []string{"src/*.go"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := matchesAnyFile(tt.files, tt.patterns)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConstraintMatch_PathsFilter(t *testing.T) {
pushTrigger := TriggerMetadata{
Kind: string(TriggerKindPush),
Push: &PushTriggerData{
Ref: "refs/heads/main",
},
}
tests := []struct {
name string
constraint Constraint
changedFiles []string
expected bool
}{
{
name: "paths match - workflow runs",
constraint: Constraint{
Event: []string{"push"},
Branch: []string{"main"},
Paths: []string{"src/**"},
},
changedFiles: []string{"src/main.go"},
expected: true,
},
{
name: "paths no match - workflow skipped",
constraint: Constraint{
Event: []string{"push"},
Branch: []string{"main"},
Paths: []string{"src/**"},
},
changedFiles: []string{"docs/guide.md"},
expected: false,
},
{
name: "no paths filter - all files pass",
constraint: Constraint{
Event: []string{"push"},
Branch: []string{"main"},
},
changedFiles: []string{"docs/guide.md"},
expected: true,
},
{
name: "paths filter with empty changed files - skipped",
constraint: Constraint{
Event: []string{"push"},
Branch: []string{"main"},
Paths: []string{"src/**"},
},
changedFiles: []string{},
expected: false,
},
{
name: "paths glob matches one of many changed files",
constraint: Constraint{
Event: []string{"push"},
Branch: []string{"main"},
Paths: []string{"**/*.go"},
},
changedFiles: []string{"README.md", "go.mod", "src/main.go"},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := tt.constraint.Match(pushTrigger, tt.changedFiles)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestUnmarshalWorkflowWithPaths(t *testing.T) {
yamlData := `
when:
- event: push
branch: main
paths:
- "src/**"
- "**.go"`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err)
assert.Len(t, wf.When, 1)
assert.ElementsMatch(t, []string{"src/**", "**.go"}, wf.When[0].Paths)
}
func TestUnmarshalWorkflowWithPathsSingleString(t *testing.T) {
yamlData := `
when:
- event: push
branch: main
paths: "src/**"`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err)
assert.Len(t, wf.When, 1)
assert.ElementsMatch(t, []string{"src/**"}, wf.When[0].Paths)
}
func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
tests := []struct {
name string
constraint Constraint
tag string
expected bool
}{
{
name: "exact match",
constraint: Constraint{Tag: []string{"v1.0.0"}},
tag: "v1.0.0",
expected: true,
},
{
name: "glob match",
constraint: Constraint{Tag: []string{"v*"}},
tag: "v2.3.4",
expected: true,
},
{
name: "no match",
constraint: Constraint{Tag: []string{"v*"}},
tag: "release-1.0",
expected: false,
},
{
name: "multiple patterns with match",
constraint: Constraint{Tag: []string{"v*", "release-*"}},
tag: "release-2024",
expected: true,
},
{
name: "empty tag list matches nothing",
constraint: Constraint{Tag: []string{}},
tag: "v1.0.0",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := tt.constraint.MatchTag(tt.tag)
assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
})
}
}
func TestUnmarshalWorkflowWithTypes(t *testing.T) {
yamlData := `
when:
- event: pull_request
types: [opened, reopened, closed]
branch: main`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err, "YAML should unmarshal without error")
assert.Len(t, wf.When, 1)
assert.ElementsMatch(t, []string{"opened", "reopened", "closed"}, wf.When[0].Types)
assert.ElementsMatch(t, []string{"pull_request"}, wf.When[0].Event)
}
func TestConstraintMatchTypes(t *testing.T) {
tests := []struct {
name string
constraint Constraint
action string
expected bool
}{
{"empty types defaults to opened", Constraint{}, PullRequestActionOpened, true},
{"empty types defaults to reopened", Constraint{}, PullRequestActionReopened, true},
{"empty types defaults to synchronize", Constraint{}, PullRequestActionSynchronize, true},
{"empty types does not match closed", Constraint{}, PullRequestActionClosed, false},
{"empty types does not match merged", Constraint{}, PullRequestActionMerged, false},
{"missing action treated as opened", Constraint{}, "", true},
{"explicit closed matches", Constraint{Types: []string{"closed"}}, PullRequestActionClosed, true},
{"explicit closed does not match opened", Constraint{Types: []string{"closed"}}, PullRequestActionOpened, false},
{"merged is distinct from closed", Constraint{Types: []string{"closed"}}, PullRequestActionMerged, false},
{"explicit merged matches", Constraint{Types: []string{"merged"}}, PullRequestActionMerged, true},
{"multiple types", Constraint{Types: []string{"opened", "closed"}}, PullRequestActionClosed, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.constraint.MatchTypes(tt.action))
})
}
}
func TestConstraintMatch_PathsFilterWithUnknownChangedFiles(t *testing.T) {
constraint := func(event string) *Constraint {
return &Constraint{Event: []string{event}, Branch: []string{"main"}, Paths: []string{"src/**"}}
}
t.Run("a push we could not diff runs", func(t *testing.T) {
trigger := TriggerMetadata{
Kind: string(TriggerKindPush),
Push: &PushTriggerData{Ref: "refs/heads/main"},
}
matched, err := constraint("push").Match(trigger, nil)
assert.NoError(t, err)
assert.True(t, matched)
})
t.Run("a pull request keeps skipping on a list that does not match", func(t *testing.T) {
trigger := TriggerMetadata{
Kind: string(TriggerKindPullRequest),
PullRequest: &PullRequestTriggerData{TargetBranch: "main"},
}
matched, err := constraint("pull_request").Match(trigger, nil)
assert.NoError(t, err)
assert.False(t, matched)
})
}
func TestConstraintMatch_PullRequestTypes(t *testing.T) {
prTrigger := func(action string) TriggerMetadata {
return TriggerMetadata{
Kind: string(TriggerKindPullRequest),
PullRequest: &PullRequestTriggerData{
Action: &action,
TargetBranch: "main",
},
}
}
tests := []struct {
name string
constraint Constraint
action string
expected bool
}{
{
name: "no types matches opened on target branch",
constraint: Constraint{Event: []string{"pull_request"}, Branch: []string{"main"}},
action: PullRequestActionOpened,
expected: true,
},
{
name: "no types does not match closed",
constraint: Constraint{Event: []string{"pull_request"}, Branch: []string{"main"}},
action: PullRequestActionClosed,
expected: false,
},
{
name: "explicit closed type matches close event",
constraint: Constraint{Event: []string{"pull_request"}, Types: []string{"closed"}, Branch: []string{"main"}},
action: PullRequestActionClosed,
expected: true,
},
{
name: "closed type does not fire on open",
constraint: Constraint{Event: []string{"pull_request"}, Types: []string{"closed"}, Branch: []string{"main"}},
action: PullRequestActionOpened,
expected: false,
},
{
name: "branch mismatch overrides matching type",
constraint: Constraint{Event: []string{"pull_request"}, Types: []string{"closed"}, Branch: []string{"release"}},
action: PullRequestActionClosed,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := tt.constraint.Match(prTrigger(tt.action), nil)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestMatch_ManualDispatch(t *testing.T) {
// manual dispatch is policy-free: every workflow matches regardless of its
// declared event/branch/tag/path constraints. Selection is the caller's job.
manualTrigger := TriggerMetadata{
Kind: string(TriggerKindManual),
Manual: &ManualTriggerData{Sha: "deadbeef"},
}
workflows := []Workflow{
{When: nil},
{When: []Constraint{{Event: []string{"push"}, Branch: []string{"main"}}}},
{When: []Constraint{{Event: []string{"pull_request"}, Paths: []string{"src/**"}}}},
{When: []Constraint{{Event: []string{"push"}, Tag: []string{"v*"}}}},
}
for i, wf := range workflows {
result, err := wf.Match(manualTrigger, nil)
assert.NoError(t, err)
assert.True(t, result, "workflow %d should match a manual dispatch", i)
}
}
func TestUnmarshalWorkflowWithScheduleEntries(t *testing.T) {
wf, err := FromFile("nightly.yml", []byte(`
when:
- event: schedule
schedule:
- cron: "30 2 * * *"
timezone: America/New_York
- cron: "0 9 * * 1-5"
timezone: Europe/London
`))
assert.NoError(t, err)
assert.Equal(t, []Schedule{
{Cron: "30 2 * * *", Timezone: "America/New_York"},
{Cron: "0 9 * * 1-5", Timezone: "Europe/London"},
}, wf.When[0].Schedule)
}
func TestUnmarshalWorkflowUsesNameBeforeHashedCronValidation(t *testing.T) {
wf, err := FromFile("hashed.yml", []byte(`
when:
- event: schedule
schedule:
- cron: "0 0 31 H *"
`))
assert.NoError(t, err)
assert.Equal(t, "hashed.yml", wf.Name)
}
func TestUnmarshalWorkflowRejectsInvalidCronSchedule(t *testing.T) {
tests := []struct {
name string
yaml string
}{
{
name: "schedule event without entries",
yaml: "when:\n - event: schedule\n",
},
{
name: "schedule entry with timezone without event",
yaml: "when:\n - event: push\n branch: main\n schedule:\n - cron: \"0 9 * * *\"\n timezone: America/New_York\n",
},
{
name: "schedule entry without cron",
yaml: "when:\n - event: schedule\n schedule:\n - {}\n",
},
{
name: "six field cron",
yaml: "when:\n - event: schedule\n schedule:\n - cron: \"0 0 9 * * *\"\n",
},
{
name: "invalid cron value",
yaml: "when:\n - event: schedule\n schedule:\n - cron: \"70 9 * * *\"\n",
},
{
name: "impossible date",
yaml: "when:\n - event: schedule\n schedule:\n - cron: \"0 0 30 2 *\"\n",
},
{
name: "invalid timezone",
yaml: "when:\n - event: schedule\n schedule:\n - cron: \"0 9 * * *\"\n timezone: Mars/Olympus_Mons\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := FromFile("invalid.yml", []byte(test.yaml))
assert.Error(t, err)
})
}
}
func TestUnmarshalWorkflowRejectsScheduleFilters(t *testing.T) {
tests := []struct {
name string
yaml string
}{
{
name: "branch",
yaml: "when:\n - event: schedule\n branch: main\n schedule:\n - cron: \"0 9 * * *\"\n",
},
{
name: "tag",
yaml: "when:\n - event: schedule\n tag: v*\n schedule:\n - cron: \"0 9 * * *\"\n",
},
{
name: "types",
yaml: "when:\n - event: schedule\n types: opened\n schedule:\n - cron: \"0 9 * * *\"\n",
},
{
name: "paths",
yaml: "when:\n - event: schedule\n paths: '**/*.go'\n schedule:\n - cron: \"0 9 * * *\"\n",
},
{
name: "schedule in event list",
yaml: "when:\n - event: [push, schedule]\n branch: main\n schedule:\n - cron: \"0 9 * * *\"\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := FromFile("invalid.yml", []byte(test.yaml))
assert.Error(t, err)
})
}
}
func TestParseCronUsesTimezone(t *testing.T) {
after := time.Date(2026, time.August, 10, 9, 29, 0, 0, time.UTC)
schedule, err := ParseCron("30 5 * * 1-5", "America/New_York", "timezone-test")
assert.NoError(t, err)
assert.Equal(t, time.Date(2026, time.August, 10, 9, 30, 0, 0, time.UTC), schedule.Next(after))
}
func TestParseCronKeepsExactExpressionsIndependentOfSeed(t *testing.T) {
after := time.Date(2026, time.August, 10, 9, 29, 0, 0, time.UTC)
first, err := ParseCron("30 5 * * 1-5", "UTC", "first-seed")
assert.NoError(t, err)
second, err := ParseCron("30 5 * * 1-5", "UTC", "second-seed")
assert.NoError(t, err)
assert.Equal(t, first.Next(after), second.Next(after))
}
func TestParseCronPreservesNamedWeekdaysAlongsideHashedTerms(t *testing.T) {
after := time.Date(2026, time.January, 1, 2, 59, 0, 0, time.UTC)
named, err := ParseCron("0 3 * * THU", "UTC", "named-weekday-seed")
assert.NoError(t, err)
assert.Equal(t, time.Thursday, named.Next(after).Weekday())
mixed, err := ParseCron("0 3 * * THU,H", "UTC", "named-weekday-seed")
assert.NoError(t, err)
assert.NotZero(t, mixed.Next(after))
}
func TestParseCronHashedFieldsAreStableAndBounded(t *testing.T) {
after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
first, err := ParseCron("H H H H *", "UTC", "stable-seed")
assert.NoError(t, err)
second, err := ParseCron("H H H H *", "UTC", "stable-seed")
assert.NoError(t, err)
firstOccurrence := first.Next(after)
secondOccurrence := second.Next(after)
assert.Equal(t, firstOccurrence, secondOccurrence)
assert.NotZero(t, firstOccurrence)
assert.GreaterOrEqual(t, firstOccurrence.Minute(), 0)
assert.LessOrEqual(t, firstOccurrence.Minute(), 59)
assert.GreaterOrEqual(t, firstOccurrence.Hour(), 0)
assert.LessOrEqual(t, firstOccurrence.Hour(), 23)
assert.GreaterOrEqual(t, firstOccurrence.Day(), 1)
assert.LessOrEqual(t, firstOccurrence.Day(), 28)
assert.GreaterOrEqual(t, int(firstOccurrence.Month()), 1)
assert.LessOrEqual(t, int(firstOccurrence.Month()), 12)
assert.GreaterOrEqual(t, int(firstOccurrence.Weekday()), 0)
assert.LessOrEqual(t, int(firstOccurrence.Weekday()), 6)
}
func TestParseCronHashedRangeAndStep(t *testing.T) {
after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
schedule, err := ParseCron("H(10-20)/5 * * * *", "UTC", "range-step-seed")
assert.NoError(t, err)
occurrence := schedule.Next(after)
assert.NotZero(t, occurrence)
offset := occurrence.Minute() % 5
for range 8 {
assert.GreaterOrEqual(t, occurrence.Minute(), 10)
assert.LessOrEqual(t, occurrence.Minute(), 20)
assert.Equal(t, offset, occurrence.Minute()%5)
occurrence = schedule.Next(occurrence)
}
}
func TestParseCronHashedStepOneKeepsJenkinsSingleValueSemantics(t *testing.T) {
after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
schedule, err := ParseCron("H/1 * * * *", "UTC", "step-one-seed")
assert.NoError(t, err)
first := schedule.Next(after)
second := schedule.Next(first)
assert.Equal(t, time.Hour, second.Sub(first))
}
func TestParseCronRejectsMalformedHashedFields(t *testing.T) {
for _, expression := range []string{
"H(foo) * * * *",
"H(20-10) * * * *",
"H(0-60) * * * *",
"H/0 * * * *",
"H(0-5)/7 * * * *",
"5H * * * *",
} {
t.Run(expression, func(t *testing.T) {
_, err := ParseCron(expression, "UTC", "malformed-seed")
assert.Error(t, err)
})
}
}
func TestHasHashedCron(t *testing.T) {
assert.True(t, HasHashedCron("H(0-29)/10 * * * *"))
assert.True(t, HasHashedCron("H * * * *"))
assert.True(t, HasHashedCron("0 3 * * THU,H"))
assert.False(t, HasHashedCron("0 3 * * THU"))
assert.False(t, HasHashedCron("*/5 * * * *"))
}
func TestParseCronAllowsIntervalsBelowHostedMinimum(t *testing.T) {
_, err := ParseCron("*/4 * * * *", "UTC", "interval-seed")
assert.NoError(t, err)
}
type stuckCronSchedule struct {
at time.Time
}
func (s stuckCronSchedule) Next(time.Time) time.Time {
return s.at
}
func TestValidateScheduleIntervalRejectsNonAdvancingSchedule(t *testing.T) {
after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
schedule := stuckCronSchedule{at: after.Add(time.Minute)}
assert.Error(t, ValidateScheduleInterval([]cron.Schedule{schedule}, 5*time.Minute, after))
}
func TestValidateScheduleInterval(t *testing.T) {
after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
closeFirst, err := ParseCron("0 * * * *", "UTC", "close-first")
assert.NoError(t, err)
closeSecond, err := ParseCron("2 * * * *", "UTC", "close-second")
assert.NoError(t, err)
assert.Error(t, ValidateScheduleInterval([]cron.Schedule{closeFirst, closeSecond}, 5*time.Minute, after))
boundary, err := ParseCron("5 * * * *", "UTC", "boundary")
assert.NoError(t, err)
assert.NoError(t, ValidateScheduleInterval([]cron.Schedule{closeFirst, boundary}, 5*time.Minute, after))
duplicate, err := ParseCron("0 9 * * *", "UTC", "duplicate")
assert.NoError(t, err)
assert.NoError(t, ValidateScheduleInterval([]cron.Schedule{duplicate, duplicate}, 5*time.Minute, after))
sparse, err := ParseCron("0 0 29 2 *", "UTC", "sparse")
assert.NoError(t, err)
assert.NoError(t, ValidateScheduleInterval([]cron.Schedule{sparse}, 5*time.Minute, after))
}