From 3396021e0f4d78f9975cca9fd88ee72341bc83fd Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 1 Jul 2026 03:26:42 +0000 Subject: [PATCH] test: Enhance Coverage + CI (#1055) Reviewed-on: https://gitea.com/gitea/runner/pulls/1055 Reviewed-by: Lunny Xiao Co-authored-by: bircni --- .gitea/workflows/test.yml | 4 + .gitignore | 2 + Makefile | 8 +- act/artifacts/server_test.go | 37 +++ act/common/context_helpers_test.go | 73 ++++++ act/common/executor_test.go | 42 ++++ act/common/git/git_test.go | 23 ++ act/container/docker_cli_test.go | 149 ++++++++++++ act/filecollector/file_collector_test.go | 61 +++++ act/lookpath/lp_unix_test.go | 74 ++++++ act/model/action_test.go | 63 +++++ act/model/planner_test.go | 132 +++++++++++ act/model/workflow_test.go | 201 ++++++++++++++++ internal/app/cmd/daemon_test.go | 40 ++++ internal/app/cmd/exec_test.go | 220 ++++++++++++++++++ internal/app/cmd/register_test.go | 137 +++++++++++ internal/app/run/runner_test.go | 103 ++++++++ internal/pkg/client/http_test.go | 68 ++++++ internal/pkg/config/registration_test.go | 51 ++++ internal/pkg/envcheck/docker_test.go | 20 ++ internal/pkg/labels/labels_test.go | 72 ++++++ internal/pkg/metrics/metrics_test.go | 95 ++++++++ internal/pkg/process/sysprocattr_unix_test.go | 23 ++ internal/pkg/process/treekill_test.go | 36 +++ internal/pkg/report/reporter_test.go | 72 ++++++ internal/pkg/ver/version_test.go | 13 ++ tools/coverage-report.ts | 162 +++++++++++++ 27 files changed, 1980 insertions(+), 1 deletion(-) create mode 100644 act/common/context_helpers_test.go create mode 100644 act/lookpath/lp_unix_test.go create mode 100644 act/model/action_test.go create mode 100644 internal/app/cmd/daemon_test.go create mode 100644 internal/app/cmd/exec_test.go create mode 100644 internal/app/run/runner_test.go create mode 100644 internal/pkg/config/registration_test.go create mode 100644 internal/pkg/envcheck/docker_test.go create mode 100644 internal/pkg/metrics/metrics_test.go create mode 100644 internal/pkg/process/sysprocattr_unix_test.go create mode 100644 internal/pkg/process/treekill_test.go create mode 100644 internal/pkg/ver/version_test.go create mode 100644 tools/coverage-report.ts diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 8879b5c7..dd5bcc82 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -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" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 14621da8..2e5bc78f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Makefile b/Makefile index 0d3182d0..4c7deeb4 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/act/artifacts/server_test.go b/act/artifacts/server_test.go index 76f8bcd5..e7a56138 100644 --- a/act/artifacts/server_test.go +++ b/act/artifacts/server_test.go @@ -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) diff --git a/act/common/context_helpers_test.go b/act/common/context_helpers_test.go new file mode 100644 index 00000000..ca21134d --- /dev/null +++ b/act/common/context_helpers_test.go @@ -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 +} diff --git a/act/common/executor_test.go b/act/common/executor_test.go index 41b988c9..ed894d80 100644 --- a/act/common/executor_test.go +++ b/act/common/executor_test.go @@ -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") + } +} diff --git a/act/common/git/git_test.go b/act/common/git/git_test.go index e19abc55..271080b7 100644 --- a/act/common/git/git_test.go +++ b/act/common/git/git_test.go @@ -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() diff --git a/act/container/docker_cli_test.go b/act/container/docker_cli_test.go index cf7e33c5..08146246 100644 --- a/act/container/docker_cli_test.go +++ b/act/container/docker_cli_test.go @@ -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 diff --git a/act/filecollector/file_collector_test.go b/act/filecollector/file_collector_test.go index 9ab462de..bdbece91 100644 --- a/act/filecollector/file_collector_test.go +++ b/act/filecollector/file_collector_test.go @@ -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 } diff --git a/act/lookpath/lp_unix_test.go b/act/lookpath/lp_unix_test.go new file mode 100644 index 00000000..ca64995d --- /dev/null +++ b/act/lookpath/lp_unix_test.go @@ -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) + } +} diff --git a/act/model/action_test.go b/act/model/action_test.go new file mode 100644 index 00000000..31ecd7a2 --- /dev/null +++ b/act/model/action_test.go @@ -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) + } +} diff --git a/act/model/planner_test.go b/act/model/planner_test.go index 515f904b..4c4f9c67 100644 --- a/act/model/planner_test.go +++ b/act/model/planner_test.go @@ -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 +} diff --git a/act/model/workflow_test.go b/act/model/workflow_test.go index 3e66d197..9f3b5bda 100644 --- a/act/model/workflow_test.go +++ b/act/model/workflow_test.go @@ -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 +} diff --git a/internal/app/cmd/daemon_test.go b/internal/app/cmd/daemon_test.go new file mode 100644 index 00000000..e90a510a --- /dev/null +++ b/internal/app/cmd/daemon_test.go @@ -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) +} diff --git a/internal/app/cmd/exec_test.go b/internal/app/cmd/exec_test.go new file mode 100644 index 00000000..b3ce7c49 --- /dev/null +++ b/internal/app/cmd/exec_test.go @@ -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() +} diff --git a/internal/app/cmd/register_test.go b/internal/app/cmd/register_test.go index 2978e6e5..031672c5 100644 --- a/internal/app/cmd/register_test.go +++ b/internal/app/cmd/register_test.go @@ -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 := ®isterInputs{ + 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, (®isterInputs{}).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 := ®isterInputs{} + 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 := ®isterInputs{} + 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 := ®isterInputs{} + 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 := ®isterInputs{} + 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 := ®isterInputs{} + 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 := ®isterInputs{} + 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 := ®isterInputs{} + 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 := ®isterInputs{} + require.Equal(t, StageUnknown, inputs.assignToNext(StageWaitingForRegistration, "x", emptyCfg)) + }) +} + +func TestInitInputs(t *testing.T) { + inputs := initInputs(®isterArgs{ + 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(®isterArgs{Labels: " "}).Labels) +} diff --git a/internal/app/run/runner_test.go b/internal/app/run/runner_test.go new file mode 100644 index 00000000..43635549 --- /dev/null +++ b/internal/app/run/runner_test.go @@ -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), + }, + }, + } +} diff --git a/internal/pkg/client/http_test.go b/internal/pkg/client/http_test.go index 9859d1d8..9cfc4ecc 100644 --- a/internal/pkg/client/http_test.go +++ b/internal/pkg/client/http_test.go @@ -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)) +} diff --git a/internal/pkg/config/registration_test.go b/internal/pkg/config/registration_test.go new file mode 100644 index 00000000..434b5f01 --- /dev/null +++ b/internal/pkg/config/registration_test.go @@ -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) +} diff --git a/internal/pkg/envcheck/docker_test.go b/internal/pkg/envcheck/docker_test.go new file mode 100644 index 00000000..e762e542 --- /dev/null +++ b/internal/pkg/envcheck/docker_test.go @@ -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") +} diff --git a/internal/pkg/labels/labels_test.go b/internal/pkg/labels/labels_test.go index e46a27bf..8b276d70 100644 --- a/internal/pkg/labels/labels_test.go +++ b/internal/pkg/labels/labels_test.go @@ -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()) +} diff --git a/internal/pkg/metrics/metrics_test.go b/internal/pkg/metrics/metrics_test.go new file mode 100644 index 00000000..470c185c --- /dev/null +++ b/internal/pkg/metrics/metrics_test.go @@ -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 +} diff --git a/internal/pkg/process/sysprocattr_unix_test.go b/internal/pkg/process/sysprocattr_unix_test.go new file mode 100644 index 00000000..48ae0def --- /dev/null +++ b/internal/pkg/process/sysprocattr_unix_test.go @@ -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) +} diff --git a/internal/pkg/process/treekill_test.go b/internal/pkg/process/treekill_test.go new file mode 100644 index 00000000..8f5dde15 --- /dev/null +++ b/internal/pkg/process/treekill_test.go @@ -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()) +} diff --git a/internal/pkg/report/reporter_test.go b/internal/pkg/report/reporter_test.go index c0da9dfe..65d097e6 100644 --- a/internal/pkg/report/reporter_test.go +++ b/internal/pkg/report/reporter_test.go @@ -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) + }) + } +} diff --git a/internal/pkg/ver/version_test.go b/internal/pkg/ver/version_test.go new file mode 100644 index 00000000..54429176 --- /dev/null +++ b/internal/pkg/ver/version_test.go @@ -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) + } +} diff --git a/tools/coverage-report.ts b/tools/coverage-report.ts new file mode 100644 index 00000000..9a1cc081 --- /dev/null +++ b/tools/coverage-report.ts @@ -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 { + const files = new Map(); + 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 { + const pkgCounts = new Map(); + const pkgFiles = new Map(); + 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)}%**  ·  ${total.covered} / ${total.total} statements covered  ·  ${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( + `
${pkg} — ${percent(c).toFixed(1)}% (${c.covered}/${c.total})\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
\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();