Files
woodpecker/pipeline/runtime/workflow_test.go
2026-03-30 11:24:36 +03:00

392 lines
11 KiB
Go

// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build test
package runtime
import (
"context"
"errors"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy"
backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types/mocks"
pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors"
tracer_mocks "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks"
)
func TestRunNilTracer(t *testing.T) {
t.Parallel()
r := New(&backend_types.Config{}, dummy.New(), WithLogger(newTestLogger(t)))
err := r.Run(t.Context())
assert.Error(t, err)
assert.Contains(t, err.Error(), "tracer must not be nil")
}
func TestRunSuccess(t *testing.T) {
t.Parallel()
tracer := newTestTracer(t)
r := New(
&backend_types.Config{
Stages: []*backend_types.Stage{{
Steps: []*backend_types.Step{{
Name: "build", UUID: "u1",
Type: backend_types.StepTypeCommands, OnSuccess: true,
Environment: map[string]string{}, Commands: []string{"echo hello"},
}},
}},
},
dummy.New(),
WithTracer(tracer),
WithLogger(newTestLogger(t)),
)
err := r.Run(t.Context())
assert.NoError(t, err)
calls := getTracerStates(tracer)
require.Len(t, calls, 2)
}
func TestRunMultipleStages(t *testing.T) {
t.Parallel()
tracer := newTestTracer(t)
r := New(
&backend_types.Config{
Stages: []*backend_types.Stage{
{Steps: []*backend_types.Step{{
Name: "stage1", UUID: "u1",
Type: backend_types.StepTypeCommands, OnSuccess: true,
Environment: map[string]string{}, Commands: []string{"echo 1"},
}}},
{Steps: []*backend_types.Step{{
Name: "stage2", UUID: "u2",
Type: backend_types.StepTypeCommands, OnSuccess: true,
Environment: map[string]string{}, Commands: []string{"echo 2"},
}}},
},
},
dummy.New(),
WithTracer(tracer),
WithLogger(newTestLogger(t)),
)
err := r.Run(t.Context())
assert.NoError(t, err)
calls := getTracerStates(tracer)
require.Len(t, calls, 4)
}
func TestRunStepError(t *testing.T) {
t.Parallel()
tracer := newTestTracer(t)
r := New(
&backend_types.Config{
Stages: []*backend_types.Stage{{
Steps: []*backend_types.Step{{
Name: "fail", UUID: "u1",
Type: backend_types.StepTypeCommands, OnSuccess: true,
Environment: map[string]string{dummy.EnvKeyStepExitCode: "1"},
Commands: []string{"exit 1"},
}},
}},
},
dummy.New(),
WithTracer(tracer),
WithLogger(newTestLogger(t)),
)
err := r.Run(t.Context())
assert.Error(t, err)
var exitErr *pipeline_errors.ExitError
assert.True(t, errors.As(err, &exitErr))
assert.Equal(t, 1, exitErr.Code)
}
func TestRunContextCanceled(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancelCause(t.Context())
cancel(nil)
r := New(
&backend_types.Config{
Stages: []*backend_types.Stage{{
Steps: []*backend_types.Step{{
Name: "s1", UUID: "u1",
Type: backend_types.StepTypeCommands, OnSuccess: true,
Environment: map[string]string{}, Commands: []string{"echo hello"},
}},
}},
},
dummy.New(),
WithTracer(newTestTracer(t)),
WithContext(ctx),
WithLogger(newTestLogger(t)),
)
err := r.Run(t.Context())
assert.ErrorIs(t, err, pipeline_errors.ErrCancel)
}
func TestRunSetupWorkflowError(t *testing.T) {
t.Parallel()
r := New(
&backend_types.Config{},
dummy.New(),
WithTracer(newTestTracer(t)),
WithTaskUUID(dummy.WorkflowSetupFailUUID),
WithLogger(newTestLogger(t)),
)
err := r.Run(t.Context())
assert.Error(t, err)
}
func TestRunSetupWorkflowInvalidSetupError(t *testing.T) {
t.Parallel()
tracer := newTestTracer(t)
step := &backend_types.Step{Name: "clone", UUID: "clone-uuid"}
setupErr := &pipeline_errors.ErrInvalidWorkflowSetup{
Err: errors.New("bad image"),
Step: step,
}
engine := mocks.NewMockBackend(t)
engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(setupErr)
engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := New(&backend_types.Config{}, engine, WithTracer(tracer), WithLogger(newTestLogger(t)))
err := r.Run(t.Context())
assert.Error(t, err)
calls := getTracerStates(tracer)
require.Len(t, calls, 1)
assert.Equal(t, step, calls[0].CurrStep)
assert.True(t, calls[0].CurrStepState.Exited)
assert.Equal(t, 1, calls[0].CurrStepState.ExitCode)
}
func TestRunDestroyWorkflowAlwaysCalled(t *testing.T) {
t.Parallel()
var destroyed int32
engine := mocks.NewMockBackend(t)
engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil)
engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).
Run(func(_ mock.Arguments) { atomic.AddInt32(&destroyed, 1) }).Return(nil)
r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))
_ = r.Run(t.Context())
assert.Equal(t, int32(1), atomic.LoadInt32(&destroyed))
}
func TestRunDestroyWorkflowCalledOnSetupError(t *testing.T) {
t.Parallel()
var destroyed int32
engine := mocks.NewMockBackend(t)
engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).
Return(errors.New("setup boom"))
engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).
Run(func(_ mock.Arguments) { atomic.AddInt32(&destroyed, 1) }).Return(nil)
r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))
_ = r.Run(t.Context())
assert.Equal(t, int32(1), atomic.LoadInt32(&destroyed))
}
func TestTraceWorkflowSetupError(t *testing.T) {
t.Parallel()
t.Run("MatchingError", func(t *testing.T) {
t.Parallel()
tracer := newTestTracer(t)
r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))
step := &backend_types.Step{Name: "setup", UUID: "su"}
err := &pipeline_errors.ErrInvalidWorkflowSetup{Err: errors.New("bad"), Step: step}
r.traceWorkflowSetupError(err)
calls := getTracerStates(tracer)
require.Len(t, calls, 1)
assert.Equal(t, step, calls[0].CurrStep)
assert.True(t, calls[0].CurrStepState.Exited)
assert.Equal(t, 1, calls[0].CurrStepState.ExitCode)
})
t.Run("NonMatchingError", func(t *testing.T) {
t.Parallel()
tracer := tracer_mocks.NewMockTracer(t)
// Trace should NOT be called — no .On() setup means test panics if called.
r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))
r.traceWorkflowSetupError(errors.New("generic error"))
})
t.Run("TracerFailure", func(t *testing.T) {
t.Parallel()
tracer := tracer_mocks.NewMockTracer(t)
tracer.On("Trace", mock.Anything).Return(errors.New("trace failed"))
r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))
step := &backend_types.Step{Name: "setup", UUID: "su"}
// Should not panic — the error is logged, not returned.
r.traceWorkflowSetupError(&pipeline_errors.ErrInvalidWorkflowSetup{
Err: errors.New("bad"), Step: step,
})
})
}
func TestRunStage(t *testing.T) {
t.Parallel()
t.Run("ParallelExecution", func(t *testing.T) {
t.Parallel()
tracer := newTestTracer(t)
r := newDummyRuntime(t, tracer)
steps := []*backend_types.Step{
{Name: "a", UUID: "ua", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo a"}},
{Name: "b", UUID: "ub", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo b"}},
{Name: "c", UUID: "uc", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo c"}},
}
err := <-r.runStage(t.Context(), steps)
assert.NoError(t, err)
assert.Len(t, getTracerStates(tracer), 6)
})
t.Run("OneStepFails", func(t *testing.T) {
t.Parallel()
tracer := newTestTracer(t)
r := newDummyRuntime(t, tracer)
steps := []*backend_types.Step{
{Name: "good", UUID: "ug", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo ok"}},
{Name: "bad", UUID: "ub", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{dummy.EnvKeyStepExitCode: "1"}, Commands: []string{"exit 1"}},
}
err := <-r.runStage(t.Context(), steps)
assert.Error(t, err)
})
}
func TestNewDefaults(t *testing.T) {
t.Parallel()
spec := &backend_types.Config{}
r := New(spec, dummy.New())
assert.Equal(t, spec, r.spec)
assert.NotEmpty(t, r.taskUUID)
assert.NotNil(t, r.ctx)
assert.Nil(t, r.tracer)
assert.NotNil(t, r.engine)
assert.NoError(t, r.err.Get())
}
func TestWithOptions(t *testing.T) {
t.Parallel()
engine := dummy.New()
tracer := newTestTracer(t)
ctx := context.Background()
desc := map[string]string{"repo": "test"}
r := New(&backend_types.Config{},
engine,
WithTracer(tracer),
WithContext(ctx),
WithDescription(desc),
WithTaskUUID("custom-uuid"),
WithLogger(newTestLogger(t)),
)
assert.Equal(t, engine, r.engine)
assert.Equal(t, tracer, r.tracer)
assert.Equal(t, ctx, r.ctx)
assert.Equal(t, "custom-uuid", r.taskUUID)
assert.Equal(t, "test", r.description["repo"])
}
func TestGetShutdownCtx(t *testing.T) {
ctx := GetShutdownCtx()
assert.NotNil(t, ctx)
ctx2 := GetShutdownCtx()
assert.Equal(t, ctx, ctx2)
}
// Gap A: logger == nil guard.
func TestRunNilLogger(t *testing.T) {
t.Parallel()
r := New(&backend_types.Config{},
dummy.New(),
WithTracer(newTestTracer(t)),
// WithLogger intentionally omitted
)
err := r.Run(t.Context())
assert.Error(t, err)
assert.Contains(t, err.Error(), "logger must not be nil")
}
// Gap B: runnerCtx is already done inside the defer → GetShutdownCtx() fallback.
func TestRunDestroyWorkflowFallsBackToShutdownCtx(t *testing.T) {
t.Parallel()
engine := mocks.NewMockBackend(t)
engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil)
var destroyCtx context.Context
engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
destroyCtx, _ = args.Get(0).(context.Context)
}).Return(nil)
// Pass a pre-canceled runnerCtx so ctx.Err() != nil in the defer.
runnerCtx, cancel := context.WithCancelCause(context.Background())
cancel(nil)
r := New(&backend_types.Config{},
engine,
WithTracer(newTestTracer(t)),
WithLogger(newTestLogger(t)),
)
_ = r.Run(runnerCtx)
require.NotNil(t, destroyCtx)
// The shutdown context is not the canceled runnerCtx — it must still be valid
// (or at least not the same canceled one).
assert.NotEqual(t, runnerCtx, destroyCtx,
"DestroyWorkflow should receive the shutdown fallback context, not the canceled runnerCtx")
}