Files
runner/e2e/modes_test.go
silverwindandbircni a46d3f3eda test(e2e): speed up and cover exec and Kubernetes modes (#1363)
Follow-up to https://gitea.com/gitea/runner/pulls/1362, whose crash e2e missed because it only drove the daemon on Docker.

- `dispatch`, `failure_paths` and `reusable_workflow` run through the daemon and `exec`, each on Docker and one shared k3s cluster.
- Fixed sleeps become gates the test releases, the job timeout uses a 15s runner deadline, k3s boots alongside Gitea, Gitea's data is on tmpfs.
- Outside a container, `make test-e2e` runs `go test` in a Linux container on its own network like CI, so local runs take the same paths.

Local wall time:

| | Before | After |
|---|---|---|
| Suite | 84s | 27–35s |
| `job_timeout` | 64s | 17s |
| `reconnect` | 22s | 4.5s |
| `capacity/serial` | 20s | 2s |
| `cli_shutdown_drains` | 14s | 2.3s |
| `kubernetes` | 31s | 21–23s |

_Written by Claude (Opus 5.5)._

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1363
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-31 10:47:35 +00:00

228 lines
6.2 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"bufio"
"bytes"
"cmp"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
type mode struct {
exec, kubernetes bool
}
var allModes = []mode{{}, {kubernetes: true}, {exec: true}, {exec: true, kubernetes: true}}
func (m mode) String() string {
driver, backend := "daemon", "docker"
if m.exec {
driver = "exec"
}
if m.kubernetes {
backend = "kubernetes"
}
return driver + "_" + backend
}
func inAllModes(scenario func(*testing.T, mode)) func(*testing.T) {
return func(t *testing.T) {
t.Parallel()
for _, m := range allModes {
t.Run(m.String(), func(t *testing.T) {
t.Parallel()
scenario(t, m)
})
}
}
}
type workflowSpec struct {
files []string
inputs map[string]string // dispatches files[0] instead of relying on push
execArgs []string
}
type workflowResult struct {
conclusion string
jobs map[string]string
lines []string
}
func (r *workflowResult) job(id string) string {
return cmp.Or(r.jobs[id], "skipped") // exec reports nothing for a job it skipped
}
func (r *workflowResult) hasLine(line string) bool {
return slices.Contains(r.lines, line)
}
func runWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
t.Helper()
run := daemonWorkflow
if m.exec {
run = execWorkflow
}
result := run(t, m, spec)
t.Cleanup(func() {
if t.Failed() {
t.Logf("%s concluded %q, jobs %v, logs:\n%s", m, result.conclusion, result.jobs, strings.Join(result.lines, "\n"))
}
})
return result
}
func daemonWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
t.Helper()
ctx := t.Context()
api, repo := newScenario(t)
if m.kubernetes {
cluster := requireCluster(t)
startRunner(t, repo, "e2e-kubernetes", runnerOptions{image: cluster.jobImage, kubernetes: cluster.kubernetes, capacity: 16})
}
files := map[string]string{}
for _, file := range spec.files {
content, err := os.ReadFile(filepath.Join("testdata", "workflows", file))
if err != nil {
t.Fatalf("read workflow fixture %s: %v", file, err)
}
if m.kubernetes {
content = bytes.ReplaceAll(content, []byte("runs-on: ubuntu-latest"), []byte("runs-on: e2e-kubernetes"))
}
files[".gitea/workflows/"+file] = string(content)
}
if err := api.CreateFiles(ctx, repo, files, "add workflows"); err != nil {
t.Fatalf("push workflows: %v", err)
}
if spec.inputs != nil {
branch, err := api.DefaultBranch(ctx, repo)
if err != nil {
t.Fatalf("get default branch: %v", err)
}
if err := waitForDispatch(ctx, api, repo, spec.files[0], branch, spec.inputs); err != nil {
t.Fatalf("dispatch workflow: %v", err)
}
}
wfRun := waitForRun(t, api, repo)
completed, err := api.WaitForRunConclusion(ctx, repo, wfRun.ID, runTimeout)
if err != nil {
dumpRunLogs(t, api, repo, wfRun.ID)
t.Fatalf("wait for run: %v", err)
}
jobs, err := api.Jobs(ctx, repo, wfRun.ID)
if err != nil {
t.Fatalf("list jobs: %v", err)
}
result := &workflowResult{conclusion: completed.Conclusion, jobs: map[string]string{}}
for _, job := range jobs {
result.jobs[job.Name] = job.Conclusion
}
for line := range strings.SplitSeq(runLogs(t, api, repo, wfRun.ID), "\n") {
if _, payload, found := strings.Cut(line, "Z "); found {
result.lines = append(result.lines, strings.TrimRight(payload, "\r"))
}
}
return result
}
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
t.Helper()
ctx := t.Context()
bin, err := runnerBinary()
if err != nil {
t.Fatal(err)
}
workdir := t.TempDir()
workflows := filepath.Join(workdir, ".gitea", "workflows")
if err := os.MkdirAll(workflows, 0o755); err != nil {
t.Fatal(err)
}
for _, file := range spec.files {
content, err := os.ReadFile(filepath.Join("testdata", "workflows", file))
if err != nil {
t.Fatalf("read workflow fixture %s: %v", file, err)
}
if err := os.WriteFile(filepath.Join(workflows, file), content, 0o644); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{
{"init", "-q"},
{"remote", "add", "origin", "https://example.com/e2e/exec.git"},
{"-c", "user.name=e2e", "-c", "[email protected]", "commit", "-q", "--allow-empty", "-m", "init"},
} {
if out, err := exec.CommandContext(ctx, "git", append([]string{"-C", workdir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
args := []string{"exec", "--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"}
env := slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
if m.kubernetes {
cluster := requireCluster(t)
args = append(args, "-i", "kubernetes://"+cluster.jobImage)
env = append(env, "KUBECONFIG="+cluster.kubernetes.Kubeconfig)
} else {
args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network)
}
if spec.inputs != nil {
args = append(args, "-E", "workflow_dispatch")
for name, value := range spec.inputs {
args = append(args, "--input", name+"="+value)
}
}
cmd := exec.CommandContext(ctx, bin, append(args, spec.execArgs...)...)
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
runErr := cmd.Run()
var exitErr *exec.ExitError
if runErr != nil && !errors.As(runErr, &exitErr) {
t.Fatalf("exec: %v\n%s", runErr, stderr.String())
}
result := &workflowResult{conclusion: "success", jobs: map[string]string{}}
if runErr != nil {
result.conclusion = "failure"
}
scanner := bufio.NewScanner(&stdout)
scanner.Buffer(nil, 1<<20)
for scanner.Scan() {
var entry struct {
JobID string `json:"jobID"`
JobResult string `json:"jobResult"`
Msg string `json:"msg"`
RawOutput bool `json:"raw_output"`
}
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
continue
}
if entry.JobResult != "" {
result.jobs[entry.JobID] = entry.JobResult
}
if entry.RawOutput {
for line := range strings.SplitSeq(strings.TrimRight(entry.Msg, "\n"), "\n") {
result.lines = append(result.lines, strings.TrimRight(line, "\r"))
}
}
}
if result.conclusion != "success" && len(result.jobs) == 0 {
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr.String())
}
return result
}