jojo/models/actions/run_job_test.go
Andreas Ahlenstorf 1127aca2d2 fix: set attempt number of action run jobs eagerly (#11750)
A Forgejo Action job should be uniquely identifiable by its `ID` and `Attempt` number. Each time a particular job is (re-)run, its `Attempt` number is incremented while its `ID` remains static. Unfortunately, `Attempt` is not incremented when the (re-)run is triggered, but right when Forgejo Runner requests the job. That makes identifying a particular run much harder, because the attempt number is changed in the midst of an attempt. Furthermore, it requires taking the job's `Status` into account. This is fixed by setting the correct attempt number right when a (re-)run is triggered. That means that the `Attempt` number remains static for the duration of a single attempt.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

(can be removed for JavaScript changes)

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

(can be removed for Go changes)

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11750
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
Co-committed-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
2026-03-20 17:23:09 +01:00

340 lines
10 KiB
Go

// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/timeutil"
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionRunJob_ItRunsOn(t *testing.T) {
actionJob := ActionRunJob{RunsOn: []string{"ubuntu"}}
agentLabels := []string{"ubuntu", "node-20"}
assert.True(t, actionJob.ItRunsOn(agentLabels))
assert.False(t, actionJob.ItRunsOn([]string{}))
actionJob.RunsOn = append(actionJob.RunsOn, "node-20")
assert.True(t, actionJob.ItRunsOn(agentLabels))
agentLabels = []string{"ubuntu"}
assert.False(t, actionJob.ItRunsOn(agentLabels))
actionJob.RunsOn = []string{}
assert.False(t, actionJob.ItRunsOn(agentLabels))
}
func TestActionRunJob_HTMLURL(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
tests := []struct {
id int64
expected string
}{
{
id: 192,
expected: "https://try.gitea.io/user5/repo4/actions/runs/187/jobs/0/attempt/1",
},
{
id: 393,
expected: "https://try.gitea.io/user2/repo1/actions/runs/187/jobs/1/attempt/1",
},
{
id: 394,
expected: "https://try.gitea.io/user2/repo1/actions/runs/187/jobs/2/attempt/2",
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("id=%d", tt.id), func(t *testing.T) {
var job ActionRunJob
has, err := db.GetEngine(t.Context()).Where("id=?", tt.id).Get(&job)
require.NoError(t, err)
require.True(t, has, "load ActionRunJob from fixture")
err = job.LoadAttributes(t.Context())
require.NoError(t, err)
url, err := job.HTMLURL(t.Context())
require.NoError(t, err)
assert.Equal(t, tt.expected, url)
})
}
}
func TestActionRunJob_HasIncompleteMatrix(t *testing.T) {
tests := []struct {
name string
job ActionRunJob
isIncomplete bool
needs *jobparser.IncompleteNeeds
errContains string
}{
{
name: "normal workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow")},
isIncomplete: false,
},
{
name: "incomplete_matrix workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow\nincomplete_matrix: true\nincomplete_matrix_needs: { job: abc }")},
needs: &jobparser.IncompleteNeeds{Job: "abc"},
isIncomplete: true,
},
{
name: "unparseable workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_matrix: true")},
errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isIncomplete, needs, err := tt.job.HasIncompleteMatrix()
if tt.errContains != "" {
assert.ErrorContains(t, err, tt.errContains)
} else {
require.NoError(t, err)
assert.Equal(t, tt.isIncomplete, isIncomplete)
assert.Equal(t, tt.needs, needs)
}
})
}
}
func TestActionRunJob_HasIncompleteRunsOn(t *testing.T) {
tests := []struct {
name string
job ActionRunJob
isIncomplete bool
needs *jobparser.IncompleteNeeds
matrix *jobparser.IncompleteMatrix
errContains string
}{
{
name: "normal workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow")},
isIncomplete: false,
},
{
name: "nincomplete_runs_on workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow\nincomplete_runs_on: true\nincomplete_runs_on_needs: { job: abc }")},
needs: &jobparser.IncompleteNeeds{Job: "abc"},
isIncomplete: true,
},
{
name: "nincomplete_runs_on workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow\nincomplete_runs_on: true\nincomplete_runs_on_matrix: { dimension: abc }")},
matrix: &jobparser.IncompleteMatrix{Dimension: "abc"},
isIncomplete: true,
},
{
name: "unparseable workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_runs_on: true")},
errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isIncomplete, needs, matrix, err := tt.job.HasIncompleteRunsOn()
if tt.errContains != "" {
assert.ErrorContains(t, err, tt.errContains)
} else {
require.NoError(t, err)
assert.Equal(t, tt.isIncomplete, isIncomplete)
assert.Equal(t, tt.needs, needs)
assert.Equal(t, tt.matrix, matrix)
}
})
}
}
func TestActionRunJob_IsWorkflowCallOuterJob(t *testing.T) {
tests := []struct {
name string
job ActionRunJob
isWorkflowCallOuterJob bool
errContains string
}{
{
name: "normal workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow")},
isWorkflowCallOuterJob: false,
},
{
name: "workflow_call outer job",
job: ActionRunJob{WorkflowPayload: []byte("name: test\njobs:\n job:\n if: false\n__metadata:\n workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed\n")},
isWorkflowCallOuterJob: true,
},
{
name: "unparseable workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_runs_on: true")},
errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isWorkflowCallOuterJob, err := tt.job.IsWorkflowCallOuterJob()
if tt.errContains != "" {
assert.ErrorContains(t, err, tt.errContains)
} else {
require.NoError(t, err)
assert.Equal(t, tt.isWorkflowCallOuterJob, isWorkflowCallOuterJob)
}
})
}
}
func TestActionRunJob_IsWorkflowCallInnerJob(t *testing.T) {
tests := []struct {
name string
job ActionRunJob
isWorkflowCallInnerJob bool
errContains string
}{
{
name: "normal workflow",
job: ActionRunJob{WorkflowPayload: []byte("on: [workflow_dispatch]\nname: workflow")},
isWorkflowCallInnerJob: false,
},
{
name: "inner job",
job: ActionRunJob{WorkflowPayload: []byte("on:\n workflow_call:\nname: workflow\n__metadata:\n workflow_call_parent: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed\n")},
isWorkflowCallInnerJob: true,
},
{
name: "unparseable workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_runs_on: true")},
errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isWorkflowCallInnerJob, err := tt.job.IsWorkflowCallInnerJob()
if tt.errContains != "" {
assert.ErrorContains(t, err, tt.errContains)
} else {
require.NoError(t, err)
assert.Equal(t, tt.isWorkflowCallInnerJob, isWorkflowCallInnerJob)
}
})
}
}
func TestActionRunJob_HasIncompleteWith(t *testing.T) {
tests := []struct {
name string
job ActionRunJob
isIncomplete bool
needs *jobparser.IncompleteNeeds
matrix *jobparser.IncompleteMatrix
errContains string
}{
{
name: "normal workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow")},
isIncomplete: false,
},
{
name: "incomplete_with workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow\nincomplete_with: true\nincomplete_with_needs: { job: abc }")},
needs: &jobparser.IncompleteNeeds{Job: "abc"},
isIncomplete: true,
},
{
name: "incomplete_with workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow\nincomplete_with: true\nincomplete_with_matrix: { dimension: abc }")},
matrix: &jobparser.IncompleteMatrix{Dimension: "abc"},
isIncomplete: true,
},
{
name: "unparseable workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_with: true")},
errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isIncomplete, needs, matrix, err := tt.job.HasIncompleteWith()
if tt.errContains != "" {
assert.ErrorContains(t, err, tt.errContains)
} else {
require.NoError(t, err)
assert.Equal(t, tt.isIncomplete, isIncomplete)
assert.Equal(t, tt.needs, needs)
assert.Equal(t, tt.matrix, matrix)
}
})
}
}
func TestRunHasOtherJobs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
jobs, err := GetRunJobsByRunID(t.Context(), 791)
require.NoError(t, err)
assert.Len(t, jobs, 1)
has, err := RunHasOtherJobs(t.Context(), 791, nil)
require.NoError(t, err)
assert.True(t, has)
has, err = RunHasOtherJobs(t.Context(), 791, []*ActionRunJob{})
require.NoError(t, err)
assert.True(t, has)
has, err = RunHasOtherJobs(t.Context(), 791, jobs)
require.NoError(t, err)
assert.False(t, has)
}
func TestActionRunJobPrepareNextAttempt(t *testing.T) {
job := ActionRunJob{ID: 46}
err := job.PrepareNextAttempt(StatusWaiting)
require.NoError(t, err)
assert.Equal(t, int64(1), job.Attempt)
assert.Zero(t, job.Started)
assert.Zero(t, job.Stopped)
assert.Zero(t, job.TaskID)
assert.Equal(t, StatusWaiting, job.Status)
job.Started = timeutil.TimeStampNow()
job.Stopped = timeutil.TimeStampNow()
job.TaskID = int64(59)
job.Status = StatusFailure
err = job.PrepareNextAttempt(StatusBlocked)
require.NoError(t, err)
assert.Equal(t, int64(2), job.Attempt)
assert.Zero(t, job.Started)
assert.Zero(t, job.Stopped)
assert.Zero(t, job.TaskID)
assert.Equal(t, StatusBlocked, job.Status)
// The job hasn't finished yet. Preparing a next attempt should not be possible. It should be left untouched.
err = job.PrepareNextAttempt(StatusWaiting)
require.ErrorContains(t, err, "cannot prepare next attempt because job 46 is active: blocked")
assert.Equal(t, int64(2), job.Attempt)
assert.Zero(t, job.Started)
assert.Zero(t, job.Stopped)
assert.Zero(t, job.TaskID)
assert.Equal(t, StatusBlocked, job.Status)
}