test(e2e): cover the register/daemon CLI and failing jobs (#1346)

The e2e suite started the runner in-process and never exercised failing jobs. This adds a scenario that builds the binary and drives `register` then `daemon` against the Gitea fixture, and one for failed, skipped, continue-on-error and timed-out jobs.

Verified locally against gitea/gitea:main-nightly.

Assisted-by: Codet:codet
Reviewed-on: https://gitea.com/gitea/runner/pulls/1346
Reviewed-by: Zettat123 <[email protected]>
This commit is contained in:
Lunny Xiao committed 2026-10-27 21:23:59 +00:00
1 parent 3dbed63f55
commit a76ccd7020
6 files changed
+236

No files matched your search

+3
View File
@@ -59,5 +59,8 @@ Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions`
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
parallel. parallel.
The `cli_register_daemon` scenario builds the binary and drives `register` and `daemon` as a user would. The
`failure_paths` scenario covers failed, skipped, tolerated and timed-out jobs.
The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`. repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
+121
View File
@@ -0,0 +1,121 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"syscall"
"testing"
"time"
)
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon.
func testCLIRegisterDaemon(t *testing.T) {
t.Parallel()
ctx := t.Context()
api, repo := newScenario(t)
token, err := fixture.RegistrationToken(ctx, repo)
if err != nil {
t.Fatalf("get registration token: %v", err)
}
dir := t.TempDir()
bin := filepath.Join(dir, "gitea-runner")
if out, err := exec.CommandContext(ctx, "go", "build", "-o", bin, "..").CombinedOutput(); err != nil {
t.Fatalf("build runner: %v\n%s", err, out)
}
cfgFile := filepath.Join(dir, "config.yaml")
cfg := fmt.Sprintf(`runner:
file: %s
fetch_interval: 250ms
fetch_interval_max: 250ms
state_report_interval: 500ms
log_report_interval: 500ms
cache:
dir: %s
`, filepath.Join(dir, ".runner"), filepath.Join(dir, "cache"))
if fixture.network != "" {
cfg += "container:\n network: " + fixture.network + "\n"
}
if err := os.WriteFile(cfgFile, []byte(cfg), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
label := "e2e-cli:docker://" + os.Getenv("E2E_JOB_IMAGE")
out, err := exec.CommandContext(ctx, bin, "register", "--no-interactive", "-c", cfgFile,
"--instance", fixture.baseURL, "--token", token, "--name", repo, "--labels", label).CombinedOutput()
if err != nil {
t.Fatalf("register: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(dir, ".runner")); err != nil {
t.Fatalf("register did not write the runner file: %v\n%s", err, out)
}
daemonLog := &syncBuffer{}
daemon := exec.Command(bin, "daemon", "-c", cfgFile)
daemon.Stdout, daemon.Stderr = daemonLog, daemonLog
if err := daemon.Start(); err != nil {
t.Fatalf("start daemon: %v", err)
}
exited := make(chan error, 1)
go func() { exited <- daemon.Wait() }()
t.Cleanup(func() {
_ = daemon.Process.Kill() // no-op once the daemon has exited
})
pushWorkflow(t, api, repo, "cli.yml")
wfRun := waitForRun(t, api, repo)
requireSuccessOrDumpDaemon(t, api, repo, wfRun.ID, daemonLog)
if logs := runLogs(t, api, repo, wfRun.ID); commandRow(logs, "e2e-cli-job-ran") == "" {
t.Errorf("job logs are missing the marker:\n%s", logs)
}
if err := daemon.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("signal daemon: %v", err)
}
select {
case err := <-exited:
if err != nil {
t.Errorf("daemon did not exit cleanly on SIGTERM: %v\n%s", err, daemonLog)
}
case <-time.After(30 * time.Second):
t.Errorf("daemon did not exit within 30s of SIGTERM\n%s", daemonLog)
}
}
func requireSuccessOrDumpDaemon(t *testing.T, api *GiteaAPI, repo string, runID int64, daemonLog *syncBuffer) {
t.Helper()
completed, err := api.WaitForRunConclusion(context.WithoutCancel(t.Context()), repo, runID, runTimeout)
if err != nil || completed.Conclusion != "success" {
t.Logf("daemon output:\n%s", daemonLog)
requireSuccess(t, api, repo, runID)
}
}
+2
View File
@@ -32,6 +32,8 @@ func TestCompatibility(t *testing.T) {
t.Run("builtin_checkout", testBuiltinCheckout) t.Run("builtin_checkout", testBuiltinCheckout)
t.Run("moving_action_ref", testMovingActionRef) t.Run("moving_action_ref", testMovingActionRef)
t.Run("ephemeral", testEphemeralRunner) t.Run("ephemeral", testEphemeralRunner)
t.Run("failure_paths", testFailurePaths)
t.Run("cli_register_daemon", testCLIRegisterDaemon)
t.Run("kubernetes", testKubernetes) t.Run("kubernetes", testKubernetes)
} }
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"strings"
"testing"
)
func testFailurePaths(t *testing.T) {
t.Parallel()
ctx := t.Context()
api, repo := newScenario(t)
pushWorkflow(t, api, repo, "failure.yml")
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)
}
if completed.Conclusion != "failure" {
t.Errorf("run concluded %q, want failure", completed.Conclusion)
}
jobs, err := api.Jobs(ctx, repo, wfRun.ID)
if err != nil {
t.Fatalf("list jobs: %v", err)
}
want := map[string]string{
"fail": "failure",
"tolerated": "success",
"dependent": "skipped",
"timeout": "failure",
}
for _, job := range jobs {
if w, ok := want[job.Name]; ok && job.Conclusion != w {
t.Errorf("job %q concluded %q, want %q", job.Name, job.Conclusion, w)
}
delete(want, job.Name)
}
for name := range want {
t.Errorf("job %q not found", name)
}
var all strings.Builder
for _, job := range jobs {
if job.Conclusion == "skipped" { // a skipped job never started, so it has no log
continue
}
jobLogs, err := api.JobLogs(ctx, repo, job.ID)
if err != nil {
t.Fatalf("job logs for %s: %v", job.Name, err)
}
all.WriteString(jobLogs)
}
logs := all.String()
for _, marker := range []string{"e2e-before-fail", "e2e-failure-cleanup", "e2e-tolerated-next", "e2e-timeout-started"} {
if commandRow(logs, marker) != marker {
t.Errorf("logs are missing %q", marker)
}
}
if commandRow(logs, "e2e-skipped-step") != "" {
t.Error("a success() step ran after a failed step")
}
if t.Failed() {
t.Logf("full stored logs:\n%s", logs)
}
}
+7
View File
@@ -0,0 +1,7 @@
name: cli
on: push
jobs:
hello:
runs-on: e2e-cli
steps:
- run: echo e2e-cli-job-ran
+30
View File
@@ -0,0 +1,30 @@
name: failure
on: push
jobs:
fail:
runs-on: ubuntu-latest
steps:
- run: echo e2e-before-fail
- run: exit 3
- if: success()
run: echo e2e-skipped-step
- if: failure()
run: echo e2e-failure-cleanup
tolerated:
runs-on: ubuntu-latest
steps:
- continue-on-error: true
run: exit 1
- run: echo e2e-tolerated-next
dependent:
needs: fail
runs-on: ubuntu-latest
steps:
- run: echo dependent
timeout:
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- run: |
echo e2e-timeout-started
sleep 300