Files
gitea/modules/actions/jobparser/model_test.go
T
Lunny Xiao 2087d4a1a5 refactor: use the shared workflow model from actionslib (#38768)
Pairs with https://gitea.com/gitea/runner/pulls/1143.

Gitea depends on `gitea.com/gitea/runner` for exactly two packages:
`act/model` and `act/exprparser`, the workflow model and the expression
evaluator it needs to parse workflows and to build the task payload the
runner consumes. Pulling the whole runner module in for that is heavy
and puts shared code in the repository of one of the two consumers.

Both packages now live in `gitea.dev/actionslib` (`pkg/model`,
`pkg/exprparser`), the module Gitea and the runner already share for the
runner API, so the dependency on the runner repository is dropped here.

### Changes

- `gitea.com/gitea/runner/act/model` ->
`gitea.dev/actionslib/pkg/model`, `.../act/exprparser` ->
`gitea.dev/actionslib/pkg/exprparser` (22 files, import paths only).
- `routers/api/actions/runner/interceptor.go` takes the `x-runner-uuid`
/ `x-runner-token` names from `gitea.dev/actionslib/pkg/protocol`
instead of repeating the literals the runner also has.
- `go.mod`: `gitea.com/gitea/runner` removed.

---------

Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-08-07 20:56:40 -07:00

576 lines
13 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"fmt"
"strings"
"testing"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
func TestParseRawOn(t *testing.T) {
kases := []struct {
input string
result []*Event
}{
{
input: "on: issue_comment",
result: []*Event{
{
Name: "issue_comment",
},
},
},
{
input: "on:\n push",
result: []*Event{
{
Name: "push",
},
},
},
{
input: "on:\n - push\n - pull_request",
result: []*Event{
{
Name: "push",
},
{
Name: "pull_request",
},
},
},
{
input: "on:\n push:\n branches:\n - master",
result: []*Event{
{
Name: "push",
acts: map[string][]string{
"branches": {
"master",
},
},
},
},
},
{
input: "on:\n push:\n branches: main",
result: []*Event{
{
Name: "push",
acts: map[string][]string{
"branches": {
"main",
},
},
},
},
},
{
input: "on:\n branch_protection_rule:\n types: [created, deleted]",
result: []*Event{
{
Name: "branch_protection_rule",
acts: map[string][]string{
"types": {
"created",
"deleted",
},
},
},
},
},
{
input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]",
result: []*Event{
{
Name: "project",
acts: map[string][]string{
"types": {
"created",
"deleted",
},
},
},
{
Name: "milestone",
acts: map[string][]string{
"types": {
"opened",
"deleted",
},
},
},
},
},
{
input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'",
result: []*Event{
{
Name: "pull_request",
acts: map[string][]string{
"types": {
"opened",
},
"branches": {
"releases/**",
},
},
},
},
},
{
input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'",
result: []*Event{
{
Name: "push",
acts: map[string][]string{
"branches": {
"main",
},
},
},
{
Name: "pull_request",
acts: map[string][]string{
"types": {
"opened",
},
"branches": {
"**",
},
},
},
},
},
{
input: "on:\n push:\n branches:\n - 'main'\n - 'releases/**'",
result: []*Event{
{
Name: "push",
acts: map[string][]string{
"branches": {
"main",
"releases/**",
},
},
},
},
},
{
input: "on:\n push:\n tags:\n - v1.**",
result: []*Event{
{
Name: "push",
acts: map[string][]string{
"tags": {
"v1.**",
},
},
},
},
},
{
input: "on: [pull_request, workflow_dispatch]",
result: []*Event{
{
Name: "pull_request",
},
{
Name: "workflow_dispatch",
},
},
},
{
input: "on:\n schedule:\n - cron: '20 6 * * *'",
result: []*Event{
{
Name: "schedule",
schedules: []map[string]string{
{
"cron": "20 6 * * *",
},
},
},
},
},
{
input: `on:
workflow_dispatch:
inputs:
logLevel:
description: 'Log level'
required: true
default: 'warning'
type: choice
options:
- info
- warning
- debug
tags:
description: 'Test scenario tags'
required: false
type: boolean
environment:
description: 'Environment to run tests against'
type: environment
required: true
push:
`,
result: []*Event{
{
Name: "workflow_dispatch",
inputs: []WorkflowDispatchInput{
{
Name: "logLevel",
Description: "Log level",
Required: true,
Default: "warning",
Type: "choice",
Options: []string{"info", "warning", "debug"},
},
{
Name: "tags",
Description: "Test scenario tags",
Required: false,
Type: "boolean",
},
{
Name: "environment",
Description: "Environment to run tests against",
Type: "environment",
Required: true,
},
},
},
{
Name: "push",
},
},
},
{
// `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection.
input: `on:
workflow_call:
inputs:
env:
type: string
required: true
outputs:
sha:
value: ${{ jobs.build.outputs.commit }}
secrets:
DEPLOY_KEY:
required: true
`,
result: []*Event{},
},
{
// Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces.
input: `on:
workflow_call:
inputs:
env:
type: string
push:
branches: [main]
`,
result: []*Event{
{
Name: "push",
acts: map[string][]string{"branches": {"main"}},
},
},
},
{
// Scalar form: a purely reusable workflow has no event triggers.
input: "on: workflow_call",
result: []*Event{},
},
{
// Sequence form: `workflow_call` is excluded while sibling events are kept.
input: "on:\n - push\n - workflow_call\n - pull_request",
result: []*Event{
{Name: "push"},
{Name: "pull_request"},
},
},
}
for _, kase := range kases {
t.Run(kase.input, func(t *testing.T) {
origin, err := model.ReadWorkflow(strings.NewReader(kase.input))
assert.NoError(t, err)
events, err := ParseRawOn(&origin.RawOn)
assert.NoError(t, err)
assert.Equal(t, kase.result, events, events)
})
}
}
func TestSingleWorkflow_SetJob(t *testing.T) {
t.Run("erase needs", func(t *testing.T) {
content := ReadTestdata(t, "erase_needs.in.yaml")
want := ReadTestdata(t, "erase_needs.out.yaml")
swf, err := Parse(content)
require.NoError(t, err)
builder := &strings.Builder{}
for _, v := range swf {
id, job := v.Job()
require.NoError(t, v.SetJob(id, job.EraseNeeds()))
if builder.Len() > 0 {
builder.WriteString("---\n")
}
encoder := yaml.NewEncoder(builder)
encoder.SetIndent(2)
require.NoError(t, encoder.Encode(v))
}
assert.Equal(t, string(want), builder.String())
})
}
func TestGetContinueOnError(t *testing.T) {
tests := []struct {
name string
yaml string
want bool
}{
{
name: "absent",
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n steps:\n - run: echo hi\n",
want: false,
},
{
name: "static true",
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n continue-on-error: true\n steps:\n - run: echo hi\n",
want: true,
},
{
name: "static false",
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n continue-on-error: false\n steps:\n - run: echo hi\n",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse([]byte(tt.yaml))
require.NoError(t, err)
require.Len(t, got, 1)
_, job := got[0].Job()
assert.Equal(t, tt.want, job.GetContinueOnError())
})
}
// Expression case: ${{ matrix.experimental }} must resolve per matrix variant.
t.Run("matrix expression", func(t *testing.T) {
content := ReadTestdata(t, "continue_on_error_expr.in.yaml")
got, err := Parse(content)
require.NoError(t, err)
require.Len(t, got, 2)
// sorted by matrix name: (false) before (true)
_, jobFalse := got[0].Job()
_, jobTrue := got[1].Job()
assert.False(t, jobFalse.GetContinueOnError())
assert.True(t, jobTrue.GetContinueOnError())
})
}
func TestParseMappingNode(t *testing.T) {
tests := []struct {
input string
scalars []string
datas []any
}{
{
input: "on:\n push:\n branches:\n - master",
scalars: []string{"push"},
datas: []any{
map[string]any{
"branches": []any{"master"},
},
},
},
{
input: "on:\n branch_protection_rule:\n types: [created, deleted]",
scalars: []string{"branch_protection_rule"},
datas: []any{
map[string]any{
"types": []any{"created", "deleted"},
},
},
},
{
input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]",
scalars: []string{"project", "milestone"},
datas: []any{
map[string]any{
"types": []any{"created", "deleted"},
},
map[string]any{
"types": []any{"opened", "deleted"},
},
},
},
{
input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'",
scalars: []string{"pull_request"},
datas: []any{
map[string]any{
"types": []any{"opened"},
"branches": []any{"releases/**"},
},
},
},
{
input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'",
scalars: []string{"push", "pull_request"},
datas: []any{
map[string]any{
"branches": []any{"main"},
},
map[string]any{
"types": []any{"opened"},
"branches": []any{"**"},
},
},
},
{
input: "on:\n schedule:\n - cron: '20 6 * * *'",
scalars: []string{"schedule"},
datas: []any{
[]any{map[string]any{
"cron": "20 6 * * *",
}},
},
},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(test.input))
assert.NoError(t, err)
scalars, datas, err := parseMappingNode[any](&workflow.RawOn)
assert.NoError(t, err)
assert.Equal(t, test.scalars, scalars, scalars)
assert.Equal(t, test.datas, datas, datas)
})
}
}
func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
ifExprs := []string{
`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
`${{ contains('["linux","windows"]', matrix.target) }}`,
}
want := map[string]bool{
"build (linux)": true,
"build (windows)": true,
"build (macos)": false,
}
for _, ifExpr := range ifExprs {
t.Run(ifExpr, func(t *testing.T) {
content := fmt.Sprintf(`
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
if: %s
strategy:
fail-fast: false
matrix:
target: [linux, windows, macos]
steps:
- run: echo ${{ matrix.target }}
`, ifExpr)
swfs, err := Parse([]byte(content))
require.NoError(t, err)
require.Len(t, swfs, 3)
got := make(map[string]bool, len(swfs))
for _, swf := range swfs {
id, job := swf.Job()
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil, false)
require.NoError(t, err)
got[job.Name] = shouldRun
}
assert.Equal(t, want, got)
})
}
}
func TestEvaluateJobIfExpression(t *testing.T) {
kases := []struct {
name string
ifCond string
needResult string
expected bool
}{
{name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true},
{name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true},
{name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true},
{name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false},
{name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true},
// cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:`
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
// a condition is an expression with or without `${{ }}`, literal text around one makes it a string
{name: "bare expression", ifCond: "always()", needResult: "failure", expected: true},
{name: "literal text keeps the success() default", ifCond: "x ${{ 1 }}", needResult: "failure", expected: false},
{name: "literal text around a status function drops it", ifCond: "x ${{ always() }}", needResult: "failure", expected: true},
}
for _, kase := range kases {
t.Run(kase.name, func(t *testing.T) {
content := strings.ReplaceAll(`
name: test
on: push
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
if: IF_COND
steps:
- run: echo job2
`, "IF_COND", kase.ifCond)
workflows, err := Parse([]byte(content))
require.NoError(t, err)
var job2 *Job
for _, wf := range workflows {
if id, job := wf.Job(); id == "job2" {
job2 = job
}
}
require.NotNil(t, job2)
// mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs
results := map[string]*JobResult{
"job1": {Result: kase.needResult},
"job2": {Needs: []string{"job1"}},
}
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil, false)
require.NoError(t, err)
assert.Equal(t, kase.expected, got)
})
}
}