Monorepo for Tangled
tangled.org
1package workflow
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/assert"
7 "tangled.org/core/api/tangled"
8)
9
10func TestUnmarshalWorkflowWithBranch(t *testing.T) {
11 yamlData := `
12when:
13 - event: ["push", "pull_request"]
14 branch: ["main", "develop"]`
15
16 wf, err := FromFile("test.yml", []byte(yamlData))
17 assert.NoError(t, err, "YAML should unmarshal without error")
18
19 assert.Len(t, wf.When, 1, "Should have one constraint")
20 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
21 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)
22
23 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
24}
25
26func TestUnmarshalWorkflowWithRunsOnLabels(t *testing.T) {
27 yamlData := `
28engine: microvm
29runs_on: [linux/arm64, kvm]
30when:
31 - event: push`
32
33 wf, err := FromFile("test.yml", []byte(yamlData))
34 assert.NoError(t, err)
35
36 assert.Equal(t, []string{"linux/arm64", "kvm"}, wf.RunsOn)
37}
38
39func TestUnmarshalCloneFalse(t *testing.T) {
40 yamlData := `
41when:
42 - event: pull_request_close
43
44clone:
45 skip: true
46`
47
48 wf, err := FromFile("test.yml", []byte(yamlData))
49 assert.NoError(t, err)
50
51 assert.ElementsMatch(t, []string{"pull_request_close"}, wf.When[0].Event)
52
53 assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
54}
55
56func TestUnmarshalWorkflowWithTags(t *testing.T) {
57 yamlData := `
58when:
59 - event: ["push"]
60 tag: ["v*", "release-*"]`
61
62 wf, err := FromFile("test.yml", []byte(yamlData))
63 assert.NoError(t, err, "YAML should unmarshal without error")
64
65 assert.Len(t, wf.When, 1, "Should have one constraint")
66 assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
67 assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
68}
69
70func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
71 yamlData := `
72when:
73 - event: ["push"]
74 branch: ["main", "develop"]
75 tag: ["v*"]`
76
77 wf, err := FromFile("test.yml", []byte(yamlData))
78 assert.NoError(t, err, "YAML should unmarshal without error")
79
80 assert.Len(t, wf.When, 1, "Should have one constraint")
81 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
82 assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
83}
84
85func TestMatchesPattern(t *testing.T) {
86 tests := []struct {
87 name string
88 input string
89 patterns []string
90 expected bool
91 }{
92 {"exact match", "main", []string{"main"}, true},
93 {"exact match in list", "develop", []string{"main", "develop"}, true},
94 {"no match", "feature", []string{"main", "develop"}, false},
95 {"wildcard prefix", "v1.0.0", []string{"v*"}, true},
96 {"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
97 {"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
98 {"double star prefix", "release-1.0.0", []string{"release-**"}, true},
99 {"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
100 {"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
101 {"double star no match", "feature/test", []string{"release/**"}, false},
102 {"no patterns matches nothing", "anything", []string{}, false},
103 {"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
104 {"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
105 {"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
106 }
107
108 for _, tt := range tests {
109 t.Run(tt.name, func(t *testing.T) {
110 result, _ := matchesPattern(tt.input, tt.patterns)
111 assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
112 })
113 }
114}
115
116func TestConstraintMatchRef_Branches(t *testing.T) {
117 tests := []struct {
118 name string
119 constraint Constraint
120 ref string
121 expected bool
122 }{
123 {
124 name: "exact branch match",
125 constraint: Constraint{Branch: []string{"main"}},
126 ref: "refs/heads/main",
127 expected: true,
128 },
129 {
130 name: "branch glob match",
131 constraint: Constraint{Branch: []string{"feature-*"}},
132 ref: "refs/heads/feature-123",
133 expected: true,
134 },
135 {
136 name: "branch no match",
137 constraint: Constraint{Branch: []string{"main"}},
138 ref: "refs/heads/develop",
139 expected: false,
140 },
141 {
142 name: "no constraints matches nothing",
143 constraint: Constraint{},
144 ref: "refs/heads/anything",
145 expected: false,
146 },
147 }
148
149 for _, tt := range tests {
150 t.Run(tt.name, func(t *testing.T) {
151 result, _ := tt.constraint.MatchRef(tt.ref)
152 assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
153 })
154 }
155}
156
157func TestConstraintMatchRef_Tags(t *testing.T) {
158 tests := []struct {
159 name string
160 constraint Constraint
161 ref string
162 expected bool
163 }{
164 {
165 name: "exact tag match",
166 constraint: Constraint{Tag: []string{"v1.0.0"}},
167 ref: "refs/tags/v1.0.0",
168 expected: true,
169 },
170 {
171 name: "tag glob match",
172 constraint: Constraint{Tag: []string{"v*"}},
173 ref: "refs/tags/v1.2.3",
174 expected: true,
175 },
176 {
177 name: "tag glob with pattern",
178 constraint: Constraint{Tag: []string{"release-*"}},
179 ref: "refs/tags/release-2024",
180 expected: true,
181 },
182 {
183 name: "tag no match",
184 constraint: Constraint{Tag: []string{"v*"}},
185 ref: "refs/tags/release-1.0",
186 expected: false,
187 },
188 {
189 name: "tag not matched when only branch constraint",
190 constraint: Constraint{Branch: []string{"main"}},
191 ref: "refs/tags/v1.0.0",
192 expected: false,
193 },
194 }
195
196 for _, tt := range tests {
197 t.Run(tt.name, func(t *testing.T) {
198 result, _ := tt.constraint.MatchRef(tt.ref)
199 assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
200 })
201 }
202}
203
204func TestConstraintMatchRef_Combined(t *testing.T) {
205 tests := []struct {
206 name string
207 constraint Constraint
208 ref string
209 expected bool
210 }{
211 {
212 name: "matches branch in combined constraint",
213 constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
214 ref: "refs/heads/main",
215 expected: true,
216 },
217 {
218 name: "matches tag in combined constraint",
219 constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
220 ref: "refs/tags/v1.0.0",
221 expected: true,
222 },
223 {
224 name: "no match in combined constraint",
225 constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
226 ref: "refs/heads/develop",
227 expected: false,
228 },
229 {
230 name: "glob patterns in combined constraint - branch",
231 constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
232 ref: "refs/heads/release-2024",
233 expected: true,
234 },
235 {
236 name: "glob patterns in combined constraint - tag",
237 constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
238 ref: "refs/tags/v2.0.0",
239 expected: true,
240 },
241 }
242
243 for _, tt := range tests {
244 t.Run(tt.name, func(t *testing.T) {
245 result, _ := tt.constraint.MatchRef(tt.ref)
246 assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
247 })
248 }
249}
250
251func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
252 tests := []struct {
253 name string
254 constraint Constraint
255 branch string
256 expected bool
257 }{
258 {
259 name: "exact match",
260 constraint: Constraint{Branch: []string{"main"}},
261 branch: "main",
262 expected: true,
263 },
264 {
265 name: "glob match",
266 constraint: Constraint{Branch: []string{"feature-*"}},
267 branch: "feature-123",
268 expected: true,
269 },
270 {
271 name: "no match",
272 constraint: Constraint{Branch: []string{"main"}},
273 branch: "develop",
274 expected: false,
275 },
276 {
277 name: "multiple patterns with match",
278 constraint: Constraint{Branch: []string{"main", "release-*"}},
279 branch: "release-1.0",
280 expected: true,
281 },
282 }
283
284 for _, tt := range tests {
285 t.Run(tt.name, func(t *testing.T) {
286 result, _ := tt.constraint.MatchBranch(tt.branch)
287 assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
288 })
289 }
290}
291
292func TestMatchesAnyFile(t *testing.T) {
293 tests := []struct {
294 name string
295 files []string
296 patterns []string
297 expected bool
298 }{
299 {
300 name: "exact file match",
301 files: []string{"src/main.go"},
302 patterns: []string{"src/main.go"},
303 expected: true,
304 },
305 {
306 name: "glob match single star",
307 files: []string{"src/main.go"},
308 patterns: []string{"src/*.go"},
309 expected: true,
310 },
311 {
312 name: "glob match double star",
313 files: []string{"src/pkg/util.go"},
314 patterns: []string{"src/**/*.go"},
315 expected: true,
316 },
317 {
318 name: "any file in list matches",
319 files: []string{"README.md", "src/main.go", "docs/guide.md"},
320 patterns: []string{"src/**"},
321 expected: true,
322 },
323 {
324 name: "no file matches",
325 files: []string{"README.md", "docs/guide.md"},
326 patterns: []string{"src/**"},
327 expected: false,
328 },
329 {
330 name: "empty files list",
331 files: []string{},
332 patterns: []string{"src/**"},
333 expected: false,
334 },
335 {
336 name: "nil files list",
337 files: nil,
338 patterns: []string{"src/**"},
339 expected: false,
340 },
341 {
342 name: "multiple patterns, second matches",
343 files: []string{"docs/guide.md"},
344 patterns: []string{"src/**", "docs/**"},
345 expected: true,
346 },
347 {
348 name: "single star does not cross directory boundary",
349 files: []string{"src/pkg/util.go"},
350 patterns: []string{"src/*.go"},
351 expected: false,
352 },
353 }
354
355 for _, tt := range tests {
356 t.Run(tt.name, func(t *testing.T) {
357 result, err := matchesAnyFile(tt.files, tt.patterns)
358 assert.NoError(t, err)
359 assert.Equal(t, tt.expected, result)
360 })
361 }
362}
363
364func TestConstraintMatch_PathsFilter(t *testing.T) {
365 pushTrigger := tangled.Pipeline_TriggerMetadata{
366 Kind: string(TriggerKindPush),
367 Push: &tangled.Pipeline_PushTriggerData{
368 Ref: "refs/heads/main",
369 },
370 }
371
372 tests := []struct {
373 name string
374 constraint Constraint
375 changedFiles []string
376 expected bool
377 }{
378 {
379 name: "paths match - workflow runs",
380 constraint: Constraint{
381 Event: []string{"push"},
382 Branch: []string{"main"},
383 Paths: []string{"src/**"},
384 },
385 changedFiles: []string{"src/main.go"},
386 expected: true,
387 },
388 {
389 name: "paths no match - workflow skipped",
390 constraint: Constraint{
391 Event: []string{"push"},
392 Branch: []string{"main"},
393 Paths: []string{"src/**"},
394 },
395 changedFiles: []string{"docs/guide.md"},
396 expected: false,
397 },
398 {
399 name: "no paths filter - all files pass",
400 constraint: Constraint{
401 Event: []string{"push"},
402 Branch: []string{"main"},
403 },
404 changedFiles: []string{"docs/guide.md"},
405 expected: true,
406 },
407 {
408 name: "paths filter with empty changed files - skipped",
409 constraint: Constraint{
410 Event: []string{"push"},
411 Branch: []string{"main"},
412 Paths: []string{"src/**"},
413 },
414 changedFiles: []string{},
415 expected: false,
416 },
417 {
418 name: "paths glob matches one of many changed files",
419 constraint: Constraint{
420 Event: []string{"push"},
421 Branch: []string{"main"},
422 Paths: []string{"**/*.go"},
423 },
424 changedFiles: []string{"README.md", "go.mod", "src/main.go"},
425 expected: true,
426 },
427 }
428
429 for _, tt := range tests {
430 t.Run(tt.name, func(t *testing.T) {
431 result, err := tt.constraint.Match(pushTrigger, tt.changedFiles)
432 assert.NoError(t, err)
433 assert.Equal(t, tt.expected, result)
434 })
435 }
436}
437
438func TestUnmarshalWorkflowWithPaths(t *testing.T) {
439 yamlData := `
440when:
441 - event: push
442 branch: main
443 paths:
444 - "src/**"
445 - "**.go"`
446
447 wf, err := FromFile("test.yml", []byte(yamlData))
448 assert.NoError(t, err)
449 assert.Len(t, wf.When, 1)
450 assert.ElementsMatch(t, []string{"src/**", "**.go"}, wf.When[0].Paths)
451}
452
453func TestUnmarshalWorkflowWithPathsSingleString(t *testing.T) {
454 yamlData := `
455when:
456 - event: push
457 branch: main
458 paths: "src/**"`
459
460 wf, err := FromFile("test.yml", []byte(yamlData))
461 assert.NoError(t, err)
462 assert.Len(t, wf.When, 1)
463 assert.ElementsMatch(t, []string{"src/**"}, wf.When[0].Paths)
464}
465
466func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
467 tests := []struct {
468 name string
469 constraint Constraint
470 tag string
471 expected bool
472 }{
473 {
474 name: "exact match",
475 constraint: Constraint{Tag: []string{"v1.0.0"}},
476 tag: "v1.0.0",
477 expected: true,
478 },
479 {
480 name: "glob match",
481 constraint: Constraint{Tag: []string{"v*"}},
482 tag: "v2.3.4",
483 expected: true,
484 },
485 {
486 name: "no match",
487 constraint: Constraint{Tag: []string{"v*"}},
488 tag: "release-1.0",
489 expected: false,
490 },
491 {
492 name: "multiple patterns with match",
493 constraint: Constraint{Tag: []string{"v*", "release-*"}},
494 tag: "release-2024",
495 expected: true,
496 },
497 {
498 name: "empty tag list matches nothing",
499 constraint: Constraint{Tag: []string{}},
500 tag: "v1.0.0",
501 expected: false,
502 },
503 }
504
505 for _, tt := range tests {
506 t.Run(tt.name, func(t *testing.T) {
507 result, _ := tt.constraint.MatchTag(tt.tag)
508 assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
509 })
510 }
511}
512
513func TestMatch_ManualDispatch(t *testing.T) {
514 // manual dispatch is policy-free: every workflow matches regardless of its
515 // declared event/branch/tag/path constraints. Selection is the caller's job.
516 manualTrigger := tangled.Pipeline_TriggerMetadata{
517 Kind: string(TriggerKindManual),
518 Manual: &tangled.Pipeline_ManualTriggerData{Sha: "deadbeef"},
519 }
520
521 workflows := []Workflow{
522 {When: nil},
523 {When: []Constraint{{Event: []string{"push"}, Branch: []string{"main"}}}},
524 {When: []Constraint{{Event: []string{"pull_request"}, Paths: []string{"src/**"}}}},
525 {When: []Constraint{{Event: []string{"push"}, Tag: []string{"v*"}}}},
526 }
527
528 for i, wf := range workflows {
529 result, err := wf.Match(manualTrigger, nil)
530 assert.NoError(t, err)
531 assert.True(t, result, "workflow %d should match a manual dispatch", i)
532 }
533}