test: Enhance Coverage + CI (#1055)

Reviewed-on: https://gitea.com/gitea/runner/pulls/1055
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni
2026-07-01 03:26:42 +00:00
committed by Lunny Xiao
parent 745b0ab6e4
commit 3396021e0f
27 changed files with 1980 additions and 1 deletions

View File

@@ -42,3 +42,7 @@ jobs:
# after `make test` so the images it needs are already present on the host daemon.
- name: test against dind image
run: make test-dind
- name: coverage report
run: |
make coverage-report
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"

2
.gitignore vendored
View File

@@ -3,6 +3,7 @@
!/act/runner/testdata/secrets/.env
.runner
coverage.txt
.tmp/
/config.yaml
# Jetbrains
@@ -12,3 +13,4 @@ coverage.txt
__debug_bin
# gorelease binary folder
/dist
.DS_Store

View File

@@ -151,6 +151,12 @@ tidy-check: tidy
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: coverage-report
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
@mkdir -p .tmp
@node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
@echo "Wrote .tmp/coverage.md"
.PHONY: test-dind
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
@./scripts/test-dind.sh $(TARGET)
@@ -218,7 +224,7 @@ docker: ## build the docker image
.PHONY: clean
clean: ## delete binary and coverage files
$(GO) clean -x -i ./...
rm -rf coverage.txt $(EXECUTABLE) $(DIST)
rm -rf coverage.txt .tmp $(EXECUTABLE) $(DIST)
.PHONY: version
version: ## print the version

View File

@@ -390,6 +390,43 @@ func TestMkdirFsImplSafeResolve(t *testing.T) {
}
}
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
fsys := readWriteFSImpl{}
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
w, err := fsys.OpenWritable(name)
require.NoError(t, err)
_, err = w.Write([]byte("first"))
require.NoError(t, err)
require.NoError(t, w.Close())
w, err = fsys.OpenAppendable(name)
require.NoError(t, err)
_, err = w.Write([]byte("-second"))
require.NoError(t, err)
require.NoError(t, w.Close())
got, err := os.ReadFile(name)
require.NoError(t, err)
require.Equal(t, "first-second", string(got))
w, err = fsys.OpenWritable(name)
require.NoError(t, err)
_, err = w.Write([]byte("replaced"))
require.NoError(t, err)
require.NoError(t, w.Close())
got, err = os.ReadFile(name)
require.NoError(t, err)
require.Equal(t, "replaced", string(got))
}
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
require.NotNil(t, cancel)
cancel()
}
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
assert := assert.New(t)

View File

@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"testing"
"github.com/sirupsen/logrus"
)
func TestDryrunContext(t *testing.T) {
ctx := context.Background()
if Dryrun(ctx) {
t.Fatal("plain context should not be dryrun")
}
if !Dryrun(WithDryrun(ctx, true)) {
t.Fatal("WithDryrun(true) should set dryrun")
}
if Dryrun(WithDryrun(ctx, false)) {
t.Fatal("WithDryrun(false) should clear dryrun")
}
}
func TestJobErrorContainer(t *testing.T) {
ctx := context.Background()
err := errors.New("job failed")
SetJobError(ctx, err)
if got := JobError(ctx); got != nil {
t.Fatalf("JobError without container = %v, want nil", got)
}
ctx = WithJobErrorContainer(ctx)
SetJobError(ctx, err)
if got := JobError(ctx); !errors.Is(got, err) {
t.Fatalf("JobError = %v, want %v", got, err)
}
}
func TestLoggerAndHookContext(t *testing.T) {
ctx := context.Background()
if Logger(ctx) != logrus.StandardLogger() {
t.Fatal("plain context should use standard logger")
}
if LoggerHook(ctx) != nil {
t.Fatal("plain context should not have a logger hook")
}
logger := logrus.New()
ctx = WithLogger(ctx, logger)
if Logger(ctx) != logger {
t.Fatal("WithLogger should set logger")
}
hook := testHook{}
ctx = WithLoggerHook(ctx, hook)
if LoggerHook(ctx) != hook {
t.Fatal("WithLoggerHook should set hook")
}
}
type testHook struct{}
func (testHook) Levels() []logrus.Level {
return logrus.AllLevels
}
func (testHook) Fire(*logrus.Entry) error {
return nil
}

View File

@@ -7,6 +7,8 @@ package common
import (
"context"
"errors"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
@@ -170,3 +172,43 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
assert.Equal(int32(3), count.Load())
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
}
func TestExecutorConditionalsAndFinally(t *testing.T) {
ctx := context.Background()
var calls []string
record := func(name string) Executor {
return func(ctx context.Context) error {
calls = append(calls, name)
return nil
}
}
require.NoError(t, record("if-true").If(func(context.Context) bool { return true })(ctx))
require.NoError(t, record("if-false").If(func(context.Context) bool { return false })(ctx))
require.NoError(t, record("if-not").IfNot(func(context.Context) bool { return false })(ctx))
require.NoError(t, record("if-bool").IfBool(true)(ctx))
require.NoError(t, record("main").Finally(record("finally"))(ctx))
want := []string{"if-true", "if-not", "if-bool", "main", "finally"}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("calls = %v, want %v", calls, want)
}
}
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
mainErr := errors.New("main failed")
finalErr := errors.New("cleanup failed")
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background())
require.Error(t, err)
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
t.Fatalf("finally error = %q, want both cleanup and original error", err)
}
}
func TestConditionalNot(t *testing.T) {
cond := Conditional(func(context.Context) bool { return false })
if !cond.Not()(context.Background()) {
t.Fatal("inverted conditional should be true")
}
}

View File

@@ -50,6 +50,13 @@ func TestFindGitSlug(t *testing.T) {
}
}
func TestErrorWrapsCommitAndCause(t *testing.T) {
err := &Error{err: ErrShortRef, commit: "abc123"}
require.Equal(t, ErrShortRef.Error(), err.Error())
require.ErrorIs(t, err, ErrShortRef)
require.Equal(t, "abc123", err.Commit())
}
func cleanGitHooks(dir string) error {
hooksDir := filepath.Join(dir, ".git", "hooks")
files, err := os.ReadDir(hooksDir)
@@ -96,6 +103,22 @@ func TestFindGitRemoteURL(t *testing.T) {
assert.Equal(remoteURL, u)
}
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
basedir := t.TempDir()
require.NoError(t, gitCmd("init", basedir))
require.NoError(t, cleanGitHooks(basedir))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", "https://github.com/owner/repo.git"))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "git@git.example.com:team/project.git"))
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
require.NoError(t, err)
require.Equal(t, "owner/repo", slug)
slug, err = FindGithubRepo(context.Background(), basedir, "git.example.com", "ghe")
require.NoError(t, err)
require.Equal(t, "team/project", slug)
}
func TestGitFindRef(t *testing.T) {
basedir := t.TempDir()

View File

@@ -498,6 +498,79 @@ func TestParseDevice(t *testing.T) {
}
}
func TestParseDeviceByServerOS(t *testing.T) {
tests := []struct {
name string
device string
serverOS string
want container.DeviceMapping
wantErr string
}{
{
name: "linux source only",
device: "/dev/snd",
serverOS: "linux",
want: container.DeviceMapping{
PathOnHost: "/dev/snd",
PathInContainer: "/dev/snd",
CgroupPermissions: "rwm",
},
},
{
name: "linux source and mode",
device: "/dev/snd:rw",
serverOS: "linux",
want: container.DeviceMapping{
PathOnHost: "/dev/snd",
PathInContainer: "/dev/snd",
CgroupPermissions: "rw",
},
},
{
name: "linux source target and mode",
device: "/dev/snd:/container/snd:m",
serverOS: "linux",
want: container.DeviceMapping{
PathOnHost: "/dev/snd",
PathInContainer: "/container/snd",
CgroupPermissions: "m",
},
},
{
name: "windows passes value through",
device: `class/GUID`,
serverOS: "windows",
want: container.DeviceMapping{
PathOnHost: `class/GUID`,
},
},
{
name: "invalid server OS",
device: "/dev/snd",
serverOS: "plan9",
wantErr: "unknown server OS: plan9",
},
{
name: "too many linux fields",
device: "/dev/snd:/container/snd:rw:extra",
serverOS: "linux",
wantErr: "invalid device specification: /dev/snd:/container/snd:rw:extra",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseDevice(tc.device, tc.serverOS)
if tc.wantErr != "" {
assert.Error(t, err, tc.wantErr)
return
}
assert.NilError(t, err)
assert.Equal(t, got, tc.want)
})
}
}
func TestParseNetworkConfig(t *testing.T) {
tests := []struct {
name string
@@ -930,6 +1003,82 @@ func TestValidateDevice(t *testing.T) {
}
}
func TestValidateDeviceByServerOS(t *testing.T) {
tests := []struct {
name string
value string
serverOS string
want string
wantError string
}{
{
name: "linux preserves three-field container path",
value: "/host:/container/../device:rw",
serverOS: "linux",
want: "/host:/container/../device:rw",
},
{
name: "linux source path can be relative when target is absolute",
value: "relative-host:/container/device",
serverOS: "linux",
want: "relative-host:/container/device",
},
{
name: "windows defers validation",
value: `class/GUID`,
serverOS: "windows",
want: `class/GUID`,
},
{
name: "linux rejects bad mode",
value: "/host:/container:ro",
serverOS: "linux",
wantError: "bad mode specified: ro",
},
{
name: "linux target must be absolute",
value: "/host:relative",
serverOS: "linux",
wantError: "relative is not an absolute path",
},
{
name: "unknown server OS",
value: "/dev/snd",
serverOS: "plan9",
wantError: "unknown server OS: plan9",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := validateDevice(tc.value, tc.serverOS)
if tc.wantError != "" {
assert.Error(t, err, tc.wantError)
return
}
assert.NilError(t, err)
assert.Equal(t, got, tc.want)
})
}
}
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
got, err := validateDeviceCgroupRule("c 1:3 rwm")
assert.NilError(t, err)
assert.Equal(t, got, "c 1:3 rwm")
_, err = validateDeviceCgroupRule("invalid")
assert.Error(t, err, "invalid device cgroup format 'invalid'")
if invalidParameter(nil) != nil {
t.Fatal("invalidParameter(nil) should be nil")
}
err = invalidParameter(errors.New("bad input"))
assert.Assert(t, err != nil)
var invalid interface{ InvalidParameter() }
assert.Assert(t, errors.As(err, &invalid))
}
func TestParseSystemPaths(t *testing.T) {
tests := []struct {
doc string

View File

@@ -13,6 +13,7 @@ import (
"runtime"
"strings"
"testing"
"time"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
@@ -221,3 +222,63 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "target", resolved)
}
func TestDefaultFsOpenReadlinkAndWalk(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires elevated privileges on Windows")
}
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644))
require.NoError(t, os.Symlink("file.txt", filepath.Join(root, "link.txt")))
fsys := &DefaultFs{}
var walked []string
require.NoError(t, fsys.Walk(root, func(path string, info os.FileInfo, err error) error {
require.NoError(t, err)
walked = append(walked, info.Name())
return nil
}))
require.Contains(t, walked, "file.txt")
require.Contains(t, walked, "link.txt")
file, err := fsys.Open(filepath.Join(root, "file.txt"))
require.NoError(t, err)
data, err := io.ReadAll(file)
require.NoError(t, err)
require.NoError(t, file.Close())
require.Equal(t, "content", string(data))
link, err := fsys.Readlink(filepath.Join(root, "link.txt"))
require.NoError(t, err)
require.Equal(t, "file.txt", link)
}
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
fc := &FileCollector{Fs: &memoryFs{Filesystem: memfs.New()}}
walk := fc.CollectFiles(cancelledContext(t), nil)
err := walk("file", fakeFileInfo{name: "file"}, nil)
require.EqualError(t, err, "copy cancelled")
err = walk("file", fakeFileInfo{name: "file"}, os.ErrPermission)
require.ErrorIs(t, err, os.ErrPermission)
}
func cancelledContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}
type fakeFileInfo struct {
name string
}
func (f fakeFileInfo) Name() string { return f.name }
func (f fakeFileInfo) Size() int64 { return 0 }
func (f fakeFileInfo) Mode() os.FileMode { return 0o644 }
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
func (f fakeFileInfo) IsDir() bool { return false }
func (f fakeFileInfo) Sys() any { return nil }

View File

@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package lookpath
import (
"errors"
"io/fs"
"os"
"path/filepath"
"testing"
)
type testEnv map[string]string
func (e testEnv) Getenv(name string) string {
return e[name]
}
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "tool")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, err := LookPath2("tool", testEnv{"PATH": string(filepath.ListSeparator) + dir})
if err != nil {
t.Fatal(err)
}
if got != exe {
t.Fatalf("LookPath2() = %q, want %q", got, exe)
}
}
func TestLookPath2DirectPathDoesNotSearchPath(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "tool")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, err := LookPath2(exe, testEnv{"PATH": ""})
if err != nil {
t.Fatal(err)
}
if got != exe {
t.Fatalf("LookPath2() = %q, want %q", got, exe)
}
}
func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "not-executable")
if err := os.WriteFile(file, []byte("plain text"), 0o644); err != nil {
t.Fatal(err)
}
_, err := LookPath2(file, testEnv{"PATH": dir})
var pathErr *Error
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, fs.ErrPermission) {
t.Fatalf("LookPath2(non-executable) error = %v, want fs.ErrPermission wrapped in *Error", err)
}
if pathErr.Error() != fs.ErrPermission.Error() {
t.Fatalf("Error() = %q, want %q", pathErr.Error(), fs.ErrPermission.Error())
}
_, err = LookPath2("missing", testEnv{"PATH": dir})
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, ErrNotFound) {
t.Fatalf("LookPath2(missing) error = %v, want ErrNotFound wrapped in *Error", err)
}
}

63
act/model/action_test.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import (
"strings"
"testing"
)
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
name: example
runs:
using: NoDe24
main: dist/index.js
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.Using != ActionRunsUsingNode24 {
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
}
if action.Runs.PreIf != "always()" {
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
}
if action.Runs.PostIf != "always()" {
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
}
}
func TestReadActionPreservesExplicitConditions(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
runs:
using: composite
pre-if: success()
post-if: failure()
steps:
- run: echo hello
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
}
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
}
}
func TestReadActionRejectsUnknownUsing(t *testing.T) {
_, err := ReadAction(strings.NewReader(`
runs:
using: node99
`))
if err == nil {
t.Fatal("expected unknown runs.using to fail")
}
if !strings.Contains(err.Error(), "node99") {
t.Fatalf("error = %q, want invalid value", err)
}
}

View File

@@ -6,10 +6,12 @@ package model
import (
"path/filepath"
"strings"
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type WorkflowPlanTest struct {
@@ -65,3 +67,133 @@ func TestWorkflow(t *testing.T) {
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, result)
}
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
name: CI
on: [push, pull_request]
jobs:
build:
name: Build project
runs-on: ubuntu-latest
steps:
- run: make build
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: make test
`))
require.NoError(t, err)
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
eventPlan, err := planner.PlanEvent("push")
require.NoError(t, err)
require.Len(t, eventPlan.Stages, 2)
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
jobPlan, err := planner.PlanJob("test")
require.NoError(t, err)
require.Len(t, jobPlan.Stages, 2)
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
allPlan, err := planner.PlanAll()
require.NoError(t, err)
require.Len(t, allPlan.Stages, 2)
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
}
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
first := mustReadWorkflow(t, `
name: First
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: make build
`)
second := mustReadWorkflow(t, `
name: Second
on: push
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: make lint
test:
needs: lint
runs-on: ubuntu-latest
steps:
- run: make test
`)
planner := CombineWorkflowPlanner(first, second)
plan, err := planner.PlanEvent("push")
require.NoError(t, err)
require.Len(t, plan.Stages, 2)
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
empty, err := planner.PlanEvent("schedule")
require.NoError(t, err)
assert.Empty(t, empty.Stages)
}
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
workflow := mustReadWorkflow(t, `
name: Cyclic
on: push
jobs:
a:
needs: b
runs-on: ubuntu-latest
steps:
- run: echo a
b:
needs: a
runs-on: ubuntu-latest
steps:
- run: echo b
`)
planner := CombineWorkflowPlanner(workflow)
plan, err := planner.PlanJob("missing")
require.Error(t, err)
assert.Empty(t, plan.Stages)
assert.Contains(t, err.Error(), "Could not find any stages")
plan, err = planner.PlanEvent("push")
require.Error(t, err)
assert.Empty(t, plan.Stages)
assert.Contains(t, err.Error(), "unable to build dependency graph")
}
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
require.Error(t, err)
assert.Contains(t, err.Error(), "file is empty")
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
require.Error(t, err)
assert.Contains(t, err.Error(), "workflow is not valid")
}
func mustReadWorkflow(t *testing.T, content string) *Workflow {
t.Helper()
workflow, err := ReadWorkflow(strings.NewReader(content))
require.NoError(t, err)
if workflow.Name == "" {
workflow.Name = "workflow"
}
return workflow
}

View File

@@ -5,6 +5,7 @@
package model
import (
"fmt"
"strings"
"testing"
@@ -58,6 +59,190 @@ func TestJobNeedsResult(t *testing.T) {
}
}
func TestJobSetContinueOnErrorFirmFailureWins(t *testing.T) {
job := &Job{}
job.SetContinueOnError(true)
assert.True(t, job.ContinueOnError)
job.SetContinueOnError(false)
assert.False(t, job.ContinueOnError)
job.SetContinueOnError(true)
assert.False(t, job.ContinueOnError, "a later tolerated failure must not hide an earlier firm failure")
}
func TestStepStatusText(t *testing.T) {
for _, tc := range []struct {
status stepStatus
text string
}{
{StepStatusSuccess, "success"},
{StepStatusFailure, "failure"},
{StepStatusSkipped, "skipped"},
} {
t.Run(tc.text, func(t *testing.T) {
got, err := tc.status.MarshalText()
require.NoError(t, err)
assert.Equal(t, tc.text, string(got))
var parsed stepStatus
require.NoError(t, parsed.UnmarshalText(got))
assert.Equal(t, tc.status, parsed)
assert.Equal(t, tc.text, parsed.String())
})
}
var parsed stepStatus
require.Error(t, parsed.UnmarshalText([]byte("cancelled")))
assert.Empty(t, stepStatus(99).String())
}
func TestWorkflowCallConfig(t *testing.T) {
workflow, err := ReadWorkflow(strings.NewReader(`
on:
workflow_call:
inputs:
name:
required: true
type: string
outputs:
digest:
value: ${{ jobs.build.outputs.digest }}
jobs: {}
`))
require.NoError(t, err)
config := workflow.WorkflowCallConfig()
require.NotNil(t, config)
require.Contains(t, config.Inputs, "name")
assert.True(t, config.Inputs["name"].Required)
assert.Equal(t, "string", config.Inputs["name"].Type)
assert.Equal(t, "${{ jobs.build.outputs.digest }}", config.Outputs["digest"].Value)
listWorkflow, err := ReadWorkflow(strings.NewReader("on: [workflow_call]\njobs: {}\n"))
require.NoError(t, err)
assert.NotNil(t, listWorkflow.WorkflowCallConfig())
assert.Empty(t, listWorkflow.WorkflowCallConfig().Inputs)
}
func TestJobSecretsAndEnvironment(t *testing.T) {
inheritJob := readJob(t, `
secrets: inherit
env:
A: one
B: two
`)
assert.True(t, inheritJob.InheritSecrets())
assert.Nil(t, inheritJob.Secrets())
assert.Equal(t, map[string]string{"A": "one", "B": "two"}, inheritJob.Environment())
mappingJob := readJob(t, `
secrets:
TOKEN: ${{ secrets.TOKEN }}
`)
assert.False(t, mappingJob.InheritSecrets())
assert.Equal(t, map[string]string{"TOKEN": "${{ secrets.TOKEN }}"}, mappingJob.Secrets())
}
func TestJobTypeAndString(t *testing.T) {
tests := []struct {
job Job
want JobType
wantErr bool
}{
{job: Job{}, want: JobTypeDefault},
{job: Job{Uses: "./.github/workflows/reuse.yml"}, want: JobTypeReusableWorkflowLocal},
{job: Job{Uses: "owner/repo/.github/workflows/reuse.yaml@v1"}, want: JobTypeReusableWorkflowRemote},
{job: Job{Uses: "owner/repo/.github/workflows/reuse.yaml"}, want: JobTypeInvalid, wantErr: true},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("%s/%s", tc.job.Uses, tc.want), func(t *testing.T) {
got, err := tc.job.Type()
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.want, got)
})
}
assert.Equal(t, "default", JobTypeDefault.String())
assert.Equal(t, "local-reusable-workflow", JobTypeReusableWorkflowLocal.String())
assert.Equal(t, "remote-reusable-workflow", JobTypeReusableWorkflowRemote.String())
assert.Equal(t, "unknown", JobType(99).String())
}
func TestStepStringEnvironmentEnvAndType(t *testing.T) {
step := readStep(t, `
id: example
env:
DIRECT: value
with:
mixed-key: input
`)
assert.Equal(t, "example", step.String())
assert.Equal(t, map[string]string{"DIRECT": "value"}, step.Environment())
assert.Equal(t, map[string]string{"DIRECT": "value", "INPUT_MIXED-KEY": "input"}, step.GetEnv())
for _, tc := range []struct {
step Step
want StepType
}{
{step: Step{}, want: StepTypeInvalid},
{step: Step{Run: "echo hi"}, want: StepTypeRun},
{step: Step{Run: "echo hi", Uses: "actions/checkout@v4"}, want: StepTypeInvalid},
{step: Step{Uses: "docker://alpine:latest"}, want: StepTypeUsesDockerURL},
{step: Step{Uses: "./.github/workflows/reuse.yml"}, want: StepTypeReusableWorkflowLocal},
{step: Step{Uses: "owner/repo/.github/workflows/reuse.yml@v1"}, want: StepTypeReusableWorkflowRemote},
{step: Step{Uses: "./actions/local"}, want: StepTypeUsesActionLocal},
{step: Step{Uses: "actions/checkout@v4"}, want: StepTypeUsesActionRemote},
} {
t.Run(tc.want.String(), func(t *testing.T) {
assert.Equal(t, tc.want, tc.step.Type())
})
}
assert.Equal(t, "invalid", StepTypeInvalid.String())
assert.Equal(t, "run", StepTypeRun.String())
assert.Equal(t, "local-action", StepTypeUsesActionLocal.String())
assert.Equal(t, "remote-action", StepTypeUsesActionRemote.String())
assert.Equal(t, "docker", StepTypeUsesDockerURL.String())
assert.Equal(t, "local-reusable-workflow", StepTypeReusableWorkflowLocal.String())
assert.Equal(t, "remote-reusable-workflow", StepTypeReusableWorkflowRemote.String())
assert.Equal(t, "unknown", StepType(99).String())
assert.NotEmpty(t, (&Step{Uses: "actions/checkout@v4"}).UsesHash())
}
func TestWorkflowGetJobAndIDs(t *testing.T) {
workflow := &Workflow{Jobs: map[string]*Job{"build": {}}}
assert.Equal(t, []string{"build"}, workflow.GetJobIDs())
job := workflow.GetJob("build")
require.NotNil(t, job)
assert.Equal(t, "build", job.Name)
assert.Equal(t, "success()", job.If.Value)
assert.Nil(t, workflow.GetJob("missing"))
}
func TestRawConcurrencyYaml(t *testing.T) {
var expr RawConcurrency
require.NoError(t, yaml.Unmarshal([]byte("group-${{ github.ref }}"), &expr))
assert.Equal(t, "group-${{ github.ref }}", expr.RawExpression)
marshaled, err := expr.MarshalYAML()
require.NoError(t, err)
assert.Equal(t, "group-${{ github.ref }}", marshaled)
var object RawConcurrency
require.NoError(t, yaml.Unmarshal([]byte("group: ci\ncancel-in-progress: true\n"), &object))
assert.Equal(t, "ci", object.Group)
assert.Equal(t, "true", object.CancelInProgress)
marshaled, err = object.MarshalYAML()
require.NoError(t, err)
assert.Equal(t, (*objectConcurrency)(&object), marshaled)
}
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
yaml := `
name: local-action-docker-url
@@ -952,3 +1137,19 @@ func TestJobMatrixValidation(t *testing.T) {
assert.Nil(t, matrix, "matrix with nested map should return nil")
})
}
func readJob(t *testing.T, content string) *Job {
t.Helper()
var job Job
require.NoError(t, yaml.Unmarshal([]byte(content), &job))
return &job
}
func readStep(t *testing.T, content string) *Step {
t.Helper()
var step Step
require.NoError(t, yaml.Unmarshal([]byte(content), &step))
return &step
}

View File

@@ -0,0 +1,40 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"testing"
"gitea.com/gitea/runner/internal/pkg/config"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
)
func TestGetDockerSocketPathUsesConfigAndEnvironment(t *testing.T) {
got, err := getDockerSocketPath("tcp://docker.example:2376")
require.NoError(t, err)
require.Equal(t, "tcp://docker.example:2376", got)
t.Setenv("DOCKER_HOST", "unix:///tmp/docker.sock")
got, err = getDockerSocketPath("-")
require.NoError(t, err)
require.Equal(t, "unix:///tmp/docker.sock", got)
}
func TestInitLoggingSetsLevelAndCaller(t *testing.T) {
oldLevel := log.GetLevel()
oldReportCaller := log.StandardLogger().ReportCaller
t.Cleanup(func() {
log.SetLevel(oldLevel)
log.SetReportCaller(oldReportCaller)
})
cfg := &config.Config{}
cfg.Log.Level = "debug"
initLogging(cfg)
require.Equal(t, log.DebugLevel, log.GetLevel())
require.True(t, log.StandardLogger().ReportCaller)
}

View File

@@ -0,0 +1,220 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
func TestExecuteArgsResolve(t *testing.T) {
workdir := t.TempDir()
args := &executeArgs{workdir: workdir}
require.Empty(t, args.resolve(""))
require.Equal(t, filepath.Join(workdir, "sub", "file"), args.resolve("sub/file"))
abs := filepath.Join(workdir, "abs")
require.Equal(t, abs, args.resolve(abs))
}
func TestExecuteArgsPaths(t *testing.T) {
workdir := t.TempDir()
args := &executeArgs{
workdir: workdir,
workflowsPath: ".gitea/workflows",
envfile: ".env",
}
require.Equal(t, filepath.Join(workdir, ".gitea/workflows"), args.WorkflowsPath())
require.Equal(t, filepath.Join(workdir, ".env"), args.Envfile())
require.Equal(t, workdir, args.Workdir())
}
func TestExecuteArgsLoadVars(t *testing.T) {
require.Empty(t, (&executeArgs{}).LoadVars())
args := &executeArgs{vars: []string{"FOO=bar", "EMPTY", "WITH=eq=sign"}}
require.Equal(t, map[string]string{
"FOO": "bar",
"EMPTY": "",
"WITH": "eq=sign",
}, args.LoadVars())
}
func TestExecuteArgsLoadSecrets(t *testing.T) {
t.Setenv("FROMENV", "from-env-value")
args := &executeArgs{secrets: []string{"token=abc", "fromenv"}}
require.Equal(t, map[string]string{
"TOKEN": "abc",
"FROMENV": "from-env-value",
}, args.LoadSecrets())
}
func TestReadEnvs(t *testing.T) {
dir := t.TempDir()
envFile := filepath.Join(dir, ".env")
require.NoError(t, os.WriteFile(envFile, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
envs := map[string]string{"EXISTING": "keep"}
require.True(t, readEnvs(envFile, envs))
require.Equal(t, map[string]string{
"EXISTING": "keep",
"FOO": "bar",
"BAZ": "qux",
}, envs)
missing := map[string]string{}
require.False(t, readEnvs(filepath.Join(dir, "does-not-exist"), missing))
require.Empty(t, missing)
}
func TestRunExecListUsesJobEventAndAllPlans(t *testing.T) {
planner := &fakeWorkflowPlanner{
events: []string{"push", "pull_request"},
plans: map[string]*model.Plan{
"job:build": listPlan("build", "Build", "push"),
"event:push": listPlan("test", "Test", "push"),
"all": listPlan("lint", "Lint", "push"),
},
}
out := captureStdout(t, func() {
require.NoError(t, runExecList(planner, &executeArgs{job: "build"}))
require.NoError(t, runExecList(planner, &executeArgs{event: "push"}))
require.NoError(t, runExecList(planner, &executeArgs{autodetectEvent: true}))
require.NoError(t, runExecList(planner, &executeArgs{}))
})
require.Contains(t, out, "Build")
require.Contains(t, out, "Test")
require.Contains(t, out, "Lint")
require.Equal(t, []string{"job:build", "event:push", "event:push", "all"}, planner.calls)
}
func TestPrintListReportsDuplicateJobIDs(t *testing.T) {
workflowA := workflowForList("A", "a.yml", "push", "build", "Build A")
workflowB := workflowForList("B", "b.yml", "pull_request", "build", "Build B")
plan := &model.Plan{Stages: []*model.Stage{{
Runs: []*model.Run{
{Workflow: workflowA, JobID: "build"},
{Workflow: workflowB, JobID: "build"},
},
}}}
out := captureStdout(t, func() {
printList(plan)
})
require.Contains(t, out, "Workflow file")
require.Contains(t, out, "Build A")
require.Contains(t, out, "Build B")
require.Contains(t, out, "Detected multiple jobs with the same job name")
}
func TestLoadExecCmdDefinesExpectedFlags(t *testing.T) {
cmd := loadExecCmd(context.Background())
for _, name := range []string{
"list",
"job",
"event",
"workflows",
"directory",
"env",
"secret",
"var",
"dryrun",
"image",
"gitea-instance",
} {
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
t.Fatalf("expected flag %q to be registered", name)
}
}
require.Equal(t, "exec", cmd.Use)
require.NoError(t, cmd.Args(cmd, strings.Split("a b c", " ")))
require.Error(t, cmd.Args(cmd, strings.Fields(strings.Repeat("arg ", 21))))
}
type fakeWorkflowPlanner struct {
events []string
plans map[string]*model.Plan
calls []string
}
func (p *fakeWorkflowPlanner) PlanEvent(eventName string) (*model.Plan, error) {
p.calls = append(p.calls, "event:"+eventName)
return p.plans["event:"+eventName], nil
}
func (p *fakeWorkflowPlanner) PlanJob(jobName string) (*model.Plan, error) {
p.calls = append(p.calls, "job:"+jobName)
return p.plans["job:"+jobName], nil
}
func (p *fakeWorkflowPlanner) PlanAll() (*model.Plan, error) {
p.calls = append(p.calls, "all")
return p.plans["all"], nil
}
func (p *fakeWorkflowPlanner) GetEvents() []string {
return p.events
}
func listPlan(jobID, jobName, event string) *model.Plan {
workflow := workflowForList("Workflow "+jobID, jobID+".yml", event, jobID, jobName)
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: jobID}}}}}
}
func workflowForList(name, file, event, jobID, jobName string) *model.Workflow {
return &model.Workflow{
Name: name,
File: file,
RawOn: rawOnNode(event),
Jobs: map[string]*model.Job{
jobID: {Name: jobName},
},
}
}
func rawOnNode(event string) yaml.Node {
var node yaml.Node
if err := yaml.Unmarshal([]byte(event), &node); err != nil {
panic(err)
}
return *node.Content[0]
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
fn()
require.NoError(t, w.Close())
os.Stdout = old
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
require.NoError(t, r.Close())
return buf.String()
}

View File

@@ -4,8 +4,12 @@
package cmd
import (
"os"
"testing"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/require"
"gotest.tools/v3/assert"
)
@@ -17,3 +21,136 @@ func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
})
assert.Error(t, err, "unsupported schema: invalid")
}
func TestRegisterInputsValidate(t *testing.T) {
tests := []struct {
name string
inputs registerInputs
wantErr string
}{
{
name: "empty instance address",
inputs: registerInputs{Token: "token"},
wantErr: "instance address is empty",
},
{
name: "empty token",
inputs: registerInputs{InstanceAddr: "http://localhost:3000"},
wantErr: "token is empty",
},
{
name: "invalid label",
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:vm:bad"}},
wantErr: "unsupported schema: vm",
},
{
name: "valid",
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:host"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.inputs.validate()
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
func TestValidateLabels(t *testing.T) {
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
require.Error(t, validateLabels([]string{"ubuntu:host", "ubuntu:vm:bad"}))
}
func TestRegisterInputsStageValue(t *testing.T) {
inputs := &registerInputs{
InstanceAddr: "http://localhost:3000",
Token: "token",
RunnerName: "runner",
Labels: []string{"ubuntu:host", "ubuntu:docker://node:18"},
}
require.Equal(t, "http://localhost:3000", inputs.stageValue(StageInputInstance))
require.Equal(t, "token", inputs.stageValue(StageInputToken))
require.Equal(t, "runner", inputs.stageValue(StageInputRunnerName))
require.Equal(t, "ubuntu:host,ubuntu:docker://node:18", inputs.stageValue(StageInputLabels))
require.Empty(t, (&registerInputs{}).stageValue(StageInputLabels))
require.Empty(t, inputs.stageValue(StageWaitingForRegistration))
}
func TestRegisterInputsAssignToNext(t *testing.T) {
emptyCfg := &config.Config{}
t.Run("instance and token stay on empty value", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputInstance, inputs.assignToNext(StageInputInstance, "", emptyCfg))
require.Equal(t, StageInputToken, inputs.assignToNext(StageInputToken, "", emptyCfg))
})
t.Run("instance then token then runner name", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputToken, inputs.assignToNext(StageInputInstance, "http://localhost:3000", emptyCfg))
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, StageInputRunnerName, inputs.assignToNext(StageInputToken, "token", emptyCfg))
require.Equal(t, "token", inputs.Token)
})
t.Run("empty runner name falls back to hostname", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputRunnerName, "", emptyCfg))
hostname, _ := os.Hostname()
require.Equal(t, hostname, inputs.RunnerName)
})
t.Run("labels from config skip the labels stage", func(t *testing.T) {
cfg := &config.Config{}
cfg.Runner.Labels = []string{"ubuntu:host", "ubuntu:vm:bad"}
inputs := &registerInputs{}
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
// only the valid label survives
require.Equal(t, []string{"ubuntu:host"}, inputs.Labels)
})
t.Run("blank labels input uses defaults", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputLabels, "", emptyCfg))
require.Equal(t, defaultLabels, inputs.Labels)
})
t.Run("invalid labels input loops back", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputLabels, "ubuntu:vm:bad", emptyCfg))
require.Nil(t, inputs.Labels)
})
t.Run("overwrite local config", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg))
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "y", emptyCfg))
require.Equal(t, StageExit, inputs.assignToNext(StageOverwriteLocalConfig, "n", emptyCfg))
})
t.Run("unknown stage", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageUnknown, inputs.assignToNext(StageWaitingForRegistration, "x", emptyCfg))
})
}
func TestInitInputs(t *testing.T) {
inputs := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
Token: "token",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "token", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
require.Nil(t, initInputs(&registerArgs{Labels: " "}).Labels)
}

View File

@@ -0,0 +1,103 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"context"
"testing"
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
func TestRunnerCapabilitiesAndDeclare(t *testing.T) {
require.Equal(t, []string{CapabilityCancelling}, RunnerCapabilities())
cli := clientmocks.NewClient(t)
cli.On("Declare", mock.Anything, mock.MatchedBy(func(req *connect.Request[runnerv1.DeclareRequest]) bool {
return req.Msg.Version == ver.Version() &&
len(req.Msg.Labels) == 1 &&
req.Msg.Labels[0] == "ubuntu" &&
len(req.Msg.Capabilities) == 1 &&
req.Msg.Capabilities[0] == CapabilityCancelling
})).Return(connect.NewResponse(&runnerv1.DeclareResponse{}), nil)
r := &Runner{client: cli}
_, err := r.Declare(context.Background(), []string{"ubuntu"})
require.NoError(t, err)
}
func TestRunnerSetCapabilitiesFromDeclare(t *testing.T) {
r := &Runner{}
r.SetCapabilitiesFromDeclare(nil)
require.Empty(t, r.capabilities)
resp := connect.NewResponse(&runnerv1.DeclareResponse{})
resp.Header().Set("X-Gitea-Actions-Capabilities", " cancelling,cache-v2 ")
r.SetCapabilitiesFromDeclare(resp)
require.Equal(t, "cancelling,cache-v2", r.capabilities)
}
func TestRunnerDefaultActionsURLUsesMirrorOnlyForGithub(t *testing.T) {
r := &Runner{cfg: &config.Config{}}
r.cfg.Runner.GithubMirror = "https://mirror.example"
task := taskWithDefaultActionsURL("https://github.com")
require.Equal(t, "https://mirror.example", r.getDefaultActionsURL(task))
task = taskWithDefaultActionsURL("https://gitea.example")
require.Equal(t, "https://gitea.example", r.getDefaultActionsURL(task))
}
func TestRunnerRunningCountAndNullLogger(t *testing.T) {
r := &Runner{}
require.Equal(t, int64(0), r.RunningCount())
r.runningCount.Add(2)
require.Equal(t, int64(2), r.RunningCount())
logger := NullLogger{}.WithJobLogger()
require.NotNil(t, logger)
require.NotNil(t, logger.Out)
}
func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
cacheEnabled := false
cfg := &config.Config{}
cfg.Cache.Enabled = &cacheEnabled
cfg.Runner.Envs = map[string]string{"EXISTING": "value"}
reg := &config.Registration{
Name: "runner",
Labels: []string{"ubuntu:host", "bad:vm:label"},
}
cli := clientmocks.NewClient(t)
cli.On("Address").Return("https://gitea.example/").Maybe()
r := NewRunner(cfg, reg, cli)
require.Equal(t, "runner", r.name)
require.Len(t, r.labels, 1)
require.Equal(t, "value", r.envs["EXISTING"])
require.Equal(t, "https://gitea.example/api/actions_pipeline/", r.envs["ACTIONS_RUNTIME_URL"])
require.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
require.Equal(t, "true", r.envs["GITEA_ACTIONS"])
require.NotEmpty(t, r.envs["GITEA_ACTIONS_RUNNER_VERSION"])
require.Nil(t, r.cacheHandler)
}
func taskWithDefaultActionsURL(url string) *runnerv1.Task {
return &runnerv1.Task{
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"gitea_default_actions_url": structpb.NewStringValue(url),
},
},
}
}

View File

@@ -5,8 +5,12 @@ package client
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"connectrpc.com/connect"
pingv1 "gitea.dev/actions-proto-go/ping/v1"
"github.com/stretchr/testify/require"
)
@@ -25,3 +29,67 @@ func TestGetHTTPClientUsesProxyFromEnvironment(t *testing.T) {
require.NotNil(t, proxyURL)
require.Equal(t, "http://proxy.example.com:8080", proxyURL.String())
}
func TestGetHTTPClientInsecureTLS(t *testing.T) {
// insecure only takes effect for https endpoints
httpsInsecure := getHTTPClient("https://gitea.example.com", true)
transport, ok := httpsInsecure.Transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, transport.TLSClientConfig)
require.True(t, transport.TLSClientConfig.InsecureSkipVerify)
for _, tc := range []struct {
name string
endpoint string
insecure bool
}{
{"https secure", "https://gitea.example.com", false},
{"http insecure ignored", "http://gitea.example.com", true},
} {
t.Run(tc.name, func(t *testing.T) {
c := getHTTPClient(tc.endpoint, tc.insecure)
tr, ok := c.Transport.(*http.Transport)
require.True(t, ok)
require.Nil(t, tr.TLSClientConfig)
})
}
}
func TestNewSetsBaseURLAndHeaders(t *testing.T) {
var gotPath string
gotHeaders := make(http.Header)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotHeaders = r.Header.Clone()
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
// trailing slash must be trimmed before "/api/actions" is appended
c := New(server.URL+"/", false, "the-uuid", "the-token")
// Address returns the endpoint as supplied (untrimmed)
require.Equal(t, server.URL+"/", c.Address())
require.False(t, c.Insecure())
// the call is expected to fail (server returns 500), we only assert what was sent
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
require.True(t, strings.HasPrefix(gotPath, "/api/actions/"), "unexpected path %q", gotPath)
require.Equal(t, "the-uuid", gotHeaders.Get(UUIDHeader))
require.Equal(t, "the-token", gotHeaders.Get(TokenHeader))
}
func TestNewOmitsEmptyHeaders(t *testing.T) {
gotHeaders := make(http.Header)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeaders = r.Header.Clone()
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := New(server.URL, false, "", "")
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
require.Empty(t, gotHeaders.Get(UUIDHeader))
require.Empty(t, gotHeaders.Get(TokenHeader))
}

View File

@@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestSaveAndLoadRegistration(t *testing.T) {
file := filepath.Join(t.TempDir(), ".runner")
reg := &Registration{
ID: 42,
UUID: "the-uuid",
Name: "runner",
Token: "the-token",
Address: "http://localhost:3000",
Labels: []string{"ubuntu:host", "ubuntu:docker://node:18"},
Ephemeral: true,
}
require.NoError(t, SaveRegistration(file, reg))
// SaveRegistration stamps the warning onto the in-memory struct
require.Equal(t, registrationWarning, reg.Warning)
loaded, err := LoadRegistration(file)
require.NoError(t, err)
// the warning is intentionally cleared on load
require.Empty(t, loaded.Warning)
loaded.Warning = reg.Warning
require.Equal(t, reg, loaded)
}
func TestLoadRegistrationMissingFile(t *testing.T) {
_, err := LoadRegistration(filepath.Join(t.TempDir(), "does-not-exist"))
require.Error(t, err)
}
func TestLoadRegistrationInvalidJSON(t *testing.T) {
file := filepath.Join(t.TempDir(), ".runner")
require.NoError(t, os.WriteFile(file, []byte("not json"), 0o600))
_, err := LoadRegistration(file)
require.Error(t, err)
}

View File

@@ -0,0 +1,20 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package envcheck
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestCheckIfDockerRunningReturnsPingError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := CheckIfDockerRunning(ctx, "unix:///definitely/missing/docker.sock")
require.Error(t, err)
require.Contains(t, err.Error(), "cannot ping the docker daemon")
}

View File

@@ -61,3 +61,75 @@ func TestParse(t *testing.T) {
})
}
}
// mustParse parses the given label strings, failing the test on any error.
func mustParse(t *testing.T, strs ...string) Labels {
t.Helper()
ls := make(Labels, 0, len(strs))
for _, s := range strs {
l, err := Parse(s)
require.NoError(t, err)
ls = append(ls, l)
}
return ls
}
func TestRequireDocker(t *testing.T) {
tests := []struct {
name string
strs []string
want bool
}{
{"empty", nil, false},
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, mustParse(t, tt.strs...).RequireDocker())
})
}
}
func TestPickPlatform(t *testing.T) {
ls := mustParse(t,
"ubuntu:docker://node:18",
"self-hosted:host",
)
tests := []struct {
name string
runsOn []string
want string
}{
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
{"host maps to self-hosted marker", []string{"self-hosted"}, "-self-hosted"},
{"first match wins", []string{"self-hosted", "ubuntu"}, "-self-hosted"},
{"unknown falls back to default", []string{"windows"}, "docker.gitea.com/runner-images:ubuntu-latest"},
{"no runsOn falls back to default", nil, "docker.gitea.com/runner-images:ubuntu-latest"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, ls.PickPlatform(tt.runsOn))
})
}
}
func TestNames(t *testing.T) {
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host")
require.Equal(t, []string{"ubuntu", "self-hosted"}, ls.Names())
require.Empty(t, Labels{}.Names())
}
func TestToStrings(t *testing.T) {
ls := mustParse(t,
"ubuntu:docker://node:18",
"self-hosted:host",
"bare",
)
require.Equal(t, []string{
"ubuntu:docker://node:18",
"self-hosted:host",
"bare:host",
}, ls.ToStrings())
}

View File

@@ -0,0 +1,95 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package metrics
import (
"context"
"strings"
"sync"
"testing"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/require"
)
func TestResultToStatusLabel(t *testing.T) {
tests := []struct {
name string
result runnerv1.Result
want string
}{
{"success", runnerv1.Result_RESULT_SUCCESS, LabelStatusSuccess},
{"failure", runnerv1.Result_RESULT_FAILURE, LabelStatusFailure},
{"cancelled", runnerv1.Result_RESULT_CANCELLED, LabelStatusCancelled},
{"skipped", runnerv1.Result_RESULT_SKIPPED, LabelStatusSkipped},
{"unspecified", runnerv1.Result_RESULT_UNSPECIFIED, LabelStatusUnknown},
{"out of range", runnerv1.Result(999), LabelStatusUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, ResultToStatusLabel(tt.result))
})
}
}
func TestInitAndDynamicMetricRegistration(t *testing.T) {
oldRegistry := Registry
t.Cleanup(func() {
Registry = oldRegistry
})
Registry = prometheus.NewRegistry()
initOnce = sync.Once{}
Init()
Init()
RunnerInfo.WithLabelValues("test", "runner").Set(1)
RegisterUptimeFunc(time.Now().Add(-time.Second))
RegisterRunningJobsFunc(func() int64 { return 2 }, 4)
metrics, err := Registry.Gather()
require.NoError(t, err)
require.True(t, hasMetric(metrics, "gitea_runner_info"))
require.True(t, hasMetric(metrics, "gitea_runner_uptime_seconds"))
require.True(t, hasMetric(metrics, "gitea_runner_job_running"))
require.True(t, hasMetric(metrics, "gitea_runner_job_capacity_utilization_ratio"))
}
func TestRegisterRunningJobsFuncZeroCapacity(t *testing.T) {
oldRegistry := Registry
t.Cleanup(func() { Registry = oldRegistry })
Registry = prometheus.NewRegistry()
RegisterRunningJobsFunc(func() int64 { return 3 }, 0)
metrics, err := Registry.Gather()
require.NoError(t, err)
for _, mf := range metrics {
if mf.GetName() == "gitea_runner_job_capacity_utilization_ratio" {
require.Len(t, mf.GetMetric(), 1)
require.InDelta(t, 0, mf.GetMetric()[0].GetGauge().GetValue(), 0)
return
}
}
t.Fatal("capacity utilization metric not gathered")
}
func TestStartServerCanBeCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
StartServer(ctx, "127.0.0.1:0")
cancel()
}
func hasMetric(metrics []*dto.MetricFamily, name string) bool {
for _, mf := range metrics {
if strings.EqualFold(mf.GetName(), name) {
return true
}
}
return false
}

View File

@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows && !plan9
package process
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSysProcAttrUnixModes(t *testing.T) {
plain := SysProcAttr("", false)
require.True(t, plain.Setpgid)
require.False(t, plain.Setsid)
tty := SysProcAttr("", true)
require.True(t, tty.Setsid)
require.True(t, tty.Setctty)
require.False(t, tty.Setpgid)
}

View File

@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"context"
"os/exec"
"testing"
"github.com/stretchr/testify/require"
)
func TestNewTreeKillConfiguresCommand(t *testing.T) {
cmd := exec.CommandContext(context.Background(), "sleep", "1")
tk := NewTreeKill(cmd)
require.NotNil(t, tk)
require.NotNil(t, cmd.Cancel)
require.Equal(t, treeKillWaitDelay, cmd.WaitDelay)
require.NoError(t, cmd.Cancel())
}
func TestTreeKillCaptureStoresKiller(t *testing.T) {
cmd := exec.CommandContext(context.Background(), "sleep", "10")
cmd.SysProcAttr = SysProcAttr("", false)
tk := NewTreeKill(cmd)
require.NoError(t, cmd.Start())
defer func() { _ = cmd.Wait() }()
killer, err := tk.Capture(cmd.Process)
require.NoError(t, err)
require.NotNil(t, killer)
require.NoError(t, cmd.Cancel())
require.NoError(t, killer.Close())
}

View File

@@ -983,3 +983,75 @@ func TestReporter_StopHeartbeats(t *testing.T) {
assert.Greater(t, updateTaskCalls.Load(), beforeStop,
"Close() must still send a final UpdateTask after StopHeartbeats")
}
func TestAppendIfNotNil(t *testing.T) {
var s []*int
s = appendIfNotNil(s, nil)
assert.Empty(t, s)
v := 7
s = appendIfNotNil(s, &v)
require.Len(t, s, 1)
assert.Equal(t, &v, s[0])
s = appendIfNotNil(s, nil)
require.Len(t, s, 1)
}
func TestReporter_Levels(t *testing.T) {
assert.Equal(t, log.AllLevels, (&Reporter{}).Levels())
}
func TestReporter_Result(t *testing.T) {
r := &Reporter{state: &runnerv1.TaskState{Result: runnerv1.Result_RESULT_SUCCESS}}
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, r.Result())
}
func TestReporter_SetOutputs(t *testing.T) {
r := &Reporter{state: &runnerv1.TaskState{}}
r.SetOutputs(map[string]string{"foo": "bar"})
got, ok := r.outputs.Load("foo")
require.True(t, ok)
assert.Equal(t, "bar", got)
// first value wins: a later write to the same key is ignored
r.SetOutputs(map[string]string{"foo": "baz"})
got, _ = r.outputs.Load("foo")
assert.Equal(t, "bar", got)
// keys longer than 255 chars are dropped
longKey := strings.Repeat("k", 256)
r.SetOutputs(map[string]string{longKey: "v"})
_, ok = r.outputs.Load(longKey)
assert.False(t, ok)
}
func TestReporter_EffectiveCloseTimeout(t *testing.T) {
assert.Equal(t, 10*time.Second, (&Reporter{}).effectiveCloseTimeout())
assert.Equal(t, 5*time.Second, (&Reporter{closeTimeout: 5 * time.Second}).effectiveCloseTimeout())
}
func TestReporter_ParseResult(t *testing.T) {
r := &Reporter{}
tests := []struct {
name string
input any
want runnerv1.Result
wantOk bool
}{
{"job result string", "success", runnerv1.Result_RESULT_SUCCESS, true},
{"failure string", "failure", runnerv1.Result_RESULT_FAILURE, true},
{"step result stringer", runnerv1.Result_RESULT_SKIPPED, runnerv1.Result_RESULT_UNSPECIFIED, false},
{"unknown string", "bogus", runnerv1.Result_RESULT_UNSPECIFIED, false},
{"unsupported type", 123, runnerv1.Result_RESULT_UNSPECIFIED, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := r.parseResult(tt.input)
assert.Equal(t, tt.wantOk, ok)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -0,0 +1,13 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ver
import "testing"
func TestVersion(t *testing.T) {
// version defaults to "dev" and is overridden at build time via -ldflags
if got := Version(); got != version {
t.Errorf("Version() = %q, want %q", got, version)
}
}

162
tools/coverage-report.ts Normal file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env node
// Turns a `go test -coverprofile` file into a human-friendly Markdown report:
// an overall total, a per-package summary sorted worst-first, and collapsible
// per-file details for each package.
//
// Coverage is statement-weighted (covered statements / total statements),
// matching `go tool cover -func`'s total, instead of naively averaging
// per-function percentages.
//
// Usage: node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
import {readFileSync, writeFileSync} from 'node:fs';
import {basename, dirname} from 'node:path';
import {argv, exit, stderr} from 'node:process';
const modulePrefix = 'gitea.com/gitea/runner/';
type Counter = {
covered: number;
total: number;
};
function percent(c: Counter): number {
if (c.total === 0) {
return 0;
}
return (c.covered / c.total) * 100;
}
function addCounter(a: Counter, b: Counter): Counter {
return {covered: a.covered + b.covered, total: a.total + b.total};
}
function parseArgs(): {input: string; output: string} {
let input = 'coverage.txt';
let output = '.tmp/coverage.md';
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '-i' && argv[i + 1]) {
input = argv[++i];
} else if (arg === '-o' && argv[i + 1]) {
output = argv[++i];
}
}
return {input, output};
}
function parseProfile(name: string): Map<string, Counter> {
const files = new Map<string, Counter>();
const content = readFileSync(name, 'utf8');
for (const line of content.split('\n')) {
if (line === '' || line.startsWith('mode:')) {
continue;
}
// Format: path:start.col,end.col numStmt count
const colon = line.lastIndexOf(':');
const fields = line.trimEnd().split(/\s+/);
if (colon < 0 || fields.length < 3) {
continue;
}
const file = line.slice(0, colon).replace(modulePrefix, '');
const stmts = Number.parseInt(fields.at(-2)!, 10);
const count = Number.parseInt(fields.at(-1)!, 10);
if (Number.isNaN(stmts) || Number.isNaN(count)) {
continue;
}
const c = files.get(file) ?? {covered: 0, total: 0};
c.total += stmts;
if (count > 0) {
c.covered += stmts;
}
files.set(file, c);
}
return files;
}
function render(files: Map<string, Counter>): string {
const pkgCounts = new Map<string, Counter>();
const pkgFiles = new Map<string, string[]>();
let total: Counter = {covered: 0, total: 0};
for (const [file, c] of files) {
const pkg = dirname(file);
pkgCounts.set(pkg, addCounter(pkgCounts.get(pkg) ?? {covered: 0, total: 0}, c));
const names = pkgFiles.get(pkg) ?? [];
names.push(file);
pkgFiles.set(pkg, names);
total = addCounter(total, c);
}
const pkgs = [...pkgCounts.keys()];
pkgs.sort((a, b) => {
const ci = pkgCounts.get(a)!;
const cj = pkgCounts.get(b)!;
const pi = percent(ci);
const pj = percent(cj);
if (pi !== pj) {
return pi - pj;
}
if (ci.total !== cj.total) {
return cj.total - ci.total;
}
return a.localeCompare(b);
});
const lines: string[] = [];
lines.push('# Coverage\n');
lines.push(
`**Total: ${percent(total).toFixed(1)}%** &nbsp;·&nbsp; ${total.covered} / ${total.total} statements covered &nbsp;·&nbsp; ${pkgs.length} packages\n`,
);
lines.push('## Packages\n');
lines.push('| Package | Coverage | Statements |');
lines.push('|---------|---------:|-----------:|');
for (const pkg of pkgs) {
const c = pkgCounts.get(pkg)!;
lines.push(`| ${pkg} | ${percent(c).toFixed(1)}% | ${c.covered} / ${c.total} |`);
}
lines.push('');
lines.push('## Files\n');
for (const pkg of pkgs) {
const c = pkgCounts.get(pkg)!;
const names = [...(pkgFiles.get(pkg) ?? [])];
names.sort((a, b) => {
const ci = files.get(a)!;
const cj = files.get(b)!;
const pi = percent(ci);
const pj = percent(cj);
if (pi !== pj) {
return pi - pj;
}
return a.localeCompare(b);
});
lines.push(
`<details><summary><strong>${pkg}</strong> — ${percent(c).toFixed(1)}% (${c.covered}/${c.total})</summary>\n`,
);
lines.push('| File | Coverage | Statements |');
lines.push('|------|---------:|-----------:|');
for (const file of names) {
const fc = files.get(file)!;
lines.push(`| ${basename(file)} | ${percent(fc).toFixed(1)}% | ${fc.covered} / ${fc.total} |`);
}
lines.push('\n</details>\n');
}
return lines.join('\n');
}
function main(): void {
const {input, output} = parseArgs();
try {
const files = parseProfile(input);
writeFileSync(output, render(files), {mode: 0o644});
} catch (err) {
stderr.write(`coverage-report: ${err}\n`);
exit(1);
}
}
main();