Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951package workflow
import ( "strconv" "strings" "testing" "time"
"github.com/robfig/cron/v3" "github.com/stretchr/testify/assert" "tangled.org/core/api/tangled")
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: microvmruns_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 := tangled.Pipeline_TriggerMetadata{ Kind: string(TriggerKindPush), Push: &tangled.Pipeline_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 := tangled.Pipeline_TriggerMetadata{ Kind: string(TriggerKindPush), Push: &tangled.Pipeline_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 := tangled.Pipeline_TriggerMetadata{ Kind: string(TriggerKindPullRequest), PullRequest: &tangled.Pipeline_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) tangled.Pipeline_TriggerMetadata { return tangled.Pipeline_TriggerMetadata{ Kind: string(TriggerKindPullRequest), PullRequest: &tangled.Pipeline_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 := tangled.Pipeline_TriggerMetadata{ Kind: string(TriggerKindManual), Manual: &tangled.Pipeline_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))}