test: speed up tests (#1181)

Parallelize isolated workflow tests, consolidate redundant fixtures, and replace fixed waits with deterministic synchronization. Keep the readable curl service probe and use `getent` for hostname resolution.

Measured on the same machine with `make test`:

1. Wall time: 170.50s to 136.12s, down 34.38s or 20.2%.
1. `act/runner`: 138.417s to 124.291s, down 14.126s or 10.2%.
1. `act/runner` coverage: unchanged at 85.2%.
1. `TestDockerExecAbort`: 2.514s to 0.012s package time.

Stability checks:

1. Cancellation and deadline tests: 100 race-enabled repetitions.
1. Host runner suite: 10 race-enabled repetitions.
1. Changed Docker fixtures: 3 consecutive repetitions.

Full race suite, Go and Windows lint, source checks, and security scan pass.

Assisted-by: Codex:GPT-5
Reviewed-on: https://gitea.com/gitea/runner/pulls/1181
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-08-22 20:53:55 +00:00
committed by bircni
parent 546eca312e
commit b97c61aa14
17 changed files with 129 additions and 199 deletions
+16 -9
View File
@@ -16,7 +16,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
@@ -148,12 +147,17 @@ func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mo
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
}
type endlessReader struct {
io.Reader
type interruptReader struct {
started chan struct{}
interrupted chan struct{}
stopped chan struct{}
}
func (r endlessReader) Read(_ []byte) (n int, err error) {
return 1, nil
func (r *interruptReader) Read(_ []byte) (int, error) {
close(r.started)
<-r.interrupted
close(r.stopped)
return 0, io.EOF
}
type mockConn struct {
@@ -173,15 +177,18 @@ func (m *mockConn) Close() (err error) {
func TestDockerExecAbort(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})}
conn := &mockConn{}
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
conn.On("Write", []byte{3}).
Run(func(mock.Arguments) { close(reader.interrupted) }).
Return(1, nil)
client := &mockDockerClient{}
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
HijackedResponse: mobyclient.HijackedResponse{
Conn: conn,
Reader: bufio.NewReader(endlessReader{}),
Reader: bufio.NewReader(reader),
},
}, nil)
@@ -199,11 +206,11 @@ func TestDockerExecAbort(t *testing.T) {
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
}()
time.Sleep(500 * time.Millisecond)
<-reader.started
cancel()
err := <-channel
<-reader.stopped
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
conn.AssertExpectations(t)
+3 -6
View File
@@ -107,17 +107,14 @@ func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
// A short deadline that we let elapse between steps, so no step records the error itself.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
ctx := newControllableDeadlineContext(context.Background())
var ran []string
var laterStepCtxErr error
steps := []common.Executor{
func(c context.Context) error {
func(context.Context) error {
ran = append(ran, "step1")
// Block until the job deadline elapses, then return cleanly: the interrupt lands in the loop's between-steps check, not inside a step.
<-c.Done()
ctx.expire()
return nil
},
func(c context.Context) error {
+30 -11
View File
@@ -528,18 +528,39 @@ func TestNewJobExecutor(t *testing.T) {
}
}
type controllableDeadlineContext struct {
context.Context
done chan struct{}
}
func newControllableDeadlineContext(parent context.Context) *controllableDeadlineContext {
return &controllableDeadlineContext{Context: parent, done: make(chan struct{})}
}
func (ctx *controllableDeadlineContext) Done() <-chan struct{} {
return ctx.done
}
func (ctx *controllableDeadlineContext) Err() error {
select {
case <-ctx.done:
return context.DeadlineExceeded
default:
return nil
}
}
func (ctx *controllableDeadlineContext) expire() {
close(ctx.done)
}
// TestNewJobExecutorRunsPostStepsAfterTimeout guards the timeout-minutes cleanup
// path: when a job exceeds its timeout the job context is DeadlineExceeded, but
// the post steps (cleanup hooks like actions/checkout post and cache save) must
// still run against a fresh, non-expired context, and the job must still be
// reported as failed.
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
ctx := common.WithJobErrorContainer(context.Background())
// The timeout is generous so the main step (which blocks on ctx.Done below) is
// always reached before the deadline fires; otherwise the pipeline would
// short-circuit before the step runs and the job error would never be set.
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
ctx := newControllableDeadlineContext(common.WithJobErrorContainer(context.Background()))
jim := &jobInfoMock{}
sfm := &stepFactoryMock{}
@@ -570,11 +591,9 @@ func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
sm := &stepMock{}
sfm.On("newStep", stepModel, rc).Return(sm, nil)
sm.On("pre").Return(func(ctx context.Context) error { return nil })
// The main step runs past the job timeout: it blocks until the job context is
// done, mirroring a step that overruns timeout-minutes.
sm.On("main").Return(func(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
sm.On("main").Return(func(stepCtx context.Context) error {
ctx.expire()
return stepCtx.Err()
})
var postRan bool
+29 -48
View File
@@ -14,6 +14,7 @@ import (
"runtime"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -171,6 +172,23 @@ func TestGraphEvent(t *testing.T) {
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan)
assert.Empty(t, plan.Stages)
for _, workflowPath := range []string{
"testdata/workflow_dispatch_no_inputs_mapping/workflow_dispatch.yml",
"testdata/workflow_dispatch-scalar/workflow_dispatch.yml",
} {
planner, err := model.NewWorkflowPlanner(workflowPath, true)
if !assert.NoError(t, err, workflowPath) { //nolint:testifylint // pre-existing issue from nektos/act
continue
}
plan, err := planner.PlanEvent("workflow_dispatch")
if !assert.NoError(t, err, workflowPath) || !assert.NotNil(t, plan, workflowPath) { //nolint:testifylint // pre-existing issue from nektos/act
continue
}
if assert.Len(t, plan.Stages, 1, workflowPath) {
assert.Len(t, plan.Stages[0].Runs, 1, workflowPath)
}
}
}
// these two build the same action Dockerfiles into one image tag, so they cannot overlap
@@ -276,7 +294,6 @@ func TestRunEvent(t *testing.T) {
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
{workdir, "checkout", "push", "", platforms, secrets},
{workdir, "job-container", "push", "", platforms, secrets},
{workdir, "job-container-non-root", "push", "", platforms, secrets},
{workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
{workdir, "container-hostname", "push", "", platforms, secrets},
{workdir, "matrix", "push", "", platforms, secrets},
@@ -286,17 +303,14 @@ func TestRunEvent(t *testing.T) {
{workdir, "defaults-run", "push", "", platforms, secrets},
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
{workdir, "issue-597", "push", "", platforms, secrets},
{workdir, "issue-598", "push", "", platforms, secrets},
{workdir, "if-env-act", "push", "", platforms, secrets},
{workdir, "env-and-path", "push", "", platforms, secrets},
{workdir, "environment-files", "push", "", platforms, secrets},
{workdir, "GITHUB_STATE", "push", "", platforms, secrets},
{workdir, "environment-files-parser-bug", "push", "", platforms, secrets},
{workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets},
{workdir, "outputs", "push", "", platforms, secrets},
{workdir, "networking", "push", "", platforms, secrets},
{workdir, "steps-context/conclusion", "push", "", platforms, secrets},
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
{workdir, "steps-context", "push", "", platforms, secrets},
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
{workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets},
@@ -306,8 +320,6 @@ func TestRunEvent(t *testing.T) {
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
{workdir, "workflow_call_inputs", "workflow_call", "", platforms, secrets},
{workdir, "workflow_dispatch", "workflow_dispatch", "", platforms, secrets},
{workdir, "workflow_dispatch_no_inputs_mapping", "workflow_dispatch", "", platforms, secrets},
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
{workdir, "container-volumes", "push", "", platforms, secrets},
@@ -323,14 +335,17 @@ func TestRunEvent(t *testing.T) {
{workdir, "services-empty-image", "push", "", platforms, secrets},
}
var sharedImageMu sync.Mutex
for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) {
if table.workflowPath == "container-volumes" {
// host /proc bind mounts are Linux-Docker-only
requireLinuxDocker(t)
}
if !slices.Contains(sharedImageWorkflows, table.workflowPath) {
t.Parallel()
t.Parallel()
if slices.Contains(sharedImageWorkflows, table.workflowPath) {
sharedImageMu.Lock()
defer sharedImageMu.Unlock()
}
config := &Config{
@@ -386,20 +401,17 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
{workdir, "checkout", "push", "", platforms, secrets},
{workdir, "matrix", "push", "", platforms, secrets},
{workdir, "commands", "push", "", platforms, secrets},
{workdir, "defaults-run", "push", "", platforms, secrets},
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
{workdir, "issue-597", "push", "", platforms, secrets},
{workdir, "issue-598", "push", "", platforms, secrets},
{workdir, "if-env-act", "push", "", platforms, secrets},
{workdir, "env-and-path", "push", "", platforms, secrets},
{workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets},
{workdir, "outputs", "push", "", platforms, secrets},
{workdir, "steps-context/conclusion", "push", "", platforms, secrets},
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
{workdir, "steps-context", "push", "", platforms, secrets},
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
{workdir, "evalenv", "push", "", platforms, secrets},
@@ -432,6 +444,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
}...)
}
hostPlanSlots := make(chan struct{}, 2)
for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) {
switch table.workflowPath {
@@ -440,6 +453,9 @@ func TestRunEventHostEnvironment(t *testing.T) {
case "nix-prepend-path":
requireHostTools(t, "nix")
}
t.Parallel()
hostPlanSlots <- struct{}{}
defer func() { <-hostPlanSlots }()
table.runTest(ctx, t, &Config{})
})
}
@@ -544,41 +560,6 @@ func TestRunEventSecrets(t *testing.T) {
tjfi.runTest(context.Background(), t, &Config{Secrets: secrets, Env: env})
}
func TestRunWithService(t *testing.T) {
requireDocker(t)
log.SetLevel(log.DebugLevel)
ctx := context.Background()
platforms := map[string]string{
"ubuntu-latest": "node:24-bookworm-slim",
}
workflowPath := "services"
eventName := "push"
workdir, err := filepath.Abs("testdata")
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
runnerConfig := &Config{
Workdir: workdir,
EventName: eventName,
PlatformPicker: mapPlatformPicker(platforms),
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
}
runner, err := New(runnerConfig)
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
planner, err := model.NewWorkflowPlanner("testdata/"+workflowPath, true)
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
plan, err := planner.PlanEvent(eventName)
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
err = runner.NewPlanExecutor(plan)(ctx)
assert.NoError(t, err, workflowPath)
}
func TestRunEventPullRequest(t *testing.T) {
t.Parallel()
requireDocker(t)
+4 -1
View File
@@ -1,4 +1,4 @@
name: issue-597
name: issues-597-598
on: push
@@ -13,6 +13,9 @@ jobs:
- name: My first true step
if: ${{endsWith('Hello world', 'ld')}}
run: echo "Renst the Octocat"
- name: My second true step
if: "!endsWith('Hello world', 'od')"
run: echo "Renst the Octocat"
- name: My second false step
if: "endsWith('Should not evaluate', 'o2')"
run: exit 1
-21
View File
@@ -1,21 +0,0 @@
name: issue-598
on: push
jobs:
my_first_job:
runs-on: ubuntu-latest
steps:
- name: My first false step
if: "endsWith('Hello world', 'o1')"
run: exit 1
- name: My first true step
if: "!endsWith('Hello world', 'od')"
run: echo "Renst the Octocat"
- name: My second false step
if: "endsWith('Hello world', 'o2')"
run: exit 1
- name: My third false step
if: "endsWith('Hello world', 'o2')"
run: exit 1
-11
View File
@@ -1,11 +0,0 @@
name: job-container
on: push
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:24-bookworm-slim
options: --user 1000
steps:
- run: echo PASS
+2 -2
View File
@@ -12,13 +12,13 @@ jobs:
strategy:
matrix:
os: [ubuntu-18.04, macos-latest]
node: [4, 6, 8, 10]
node: [4, 10]
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [8.x, 10.x, 12.x, 13.x]
node: [8.x, 13.x]
steps:
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
env:
+2 -8
View File
@@ -4,11 +4,5 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Install tools
run: |
apt update
apt install -y iputils-ping
- name: Run hostname test
run: |
hostname -f
ping -c 4 $(hostname -f)
- name: Resolve the container hostname
run: getent hosts "$(hostname -f)"
+4 -19
View File
@@ -12,32 +12,17 @@ jobs:
- id: set_2
run: |
echo "::set-output name=var_3::$(echo var3)"
- id: set_3
run: |
echo "::set-output name=var_4::$(echo var4)"
outputs:
variable_1: ${{ steps.set_1.outputs.var_1 }}
variable_2: ${{ steps.set_1.outputs.var_2 }}
variable_3: ${{ steps.set_2.outputs.var_3 }}
variable_4: ${{ steps.set_3.outputs.var_4 }}
build:
needs: build_output
runs-on: ubuntu-latest
steps:
- name: Check set_1 var1
- name: Check outputs
run: |
echo "${{ needs.build_output.outputs.variable_1 }}"
echo "${{ needs.build_output.outputs.variable_1 }}" | grep 'var1' || exit 1
- name: Check set_1 var2
run: |
echo "${{ needs.build_output.outputs.variable_2 }}"
echo "${{ needs.build_output.outputs.variable_2 }}" | grep 'var2' || exit 1
- name: Check set_2 var3
run: |
echo "${{ needs.build_output.outputs.variable_3 }}"
echo "${{ needs.build_output.outputs.variable_3 }}" | grep 'var3' || exit 1
- name: Check set_3 var4
run: |
echo "${{ needs.build_output.outputs.variable_4 }}"
echo "${{ needs.build_output.outputs.variable_4 }}" | grep 'var4' || exit 1
test "${{ needs.build_output.outputs.variable_1 }}" = var1
test "${{ needs.build_output.outputs.variable_2 }}" = var2
test "${{ needs.build_output.outputs.variable_3 }}" = var3
+1 -5
View File
@@ -10,14 +10,10 @@ jobs:
ports:
- 80
steps:
- name: Echo the Postgres service ID / Network / Ports
run: |
echo "id: ${{ job.services.postgres.id }}"
echo "network: ${{ job.services.postgres.network }}"
echo "ports: ${{ job.services.postgres.ports }}"
- name: The job context describes the started containers
run: |
test -n "${{ job.container.id }}"
test -n "${{ job.services.postgres.id }}"
test -n "${{ job.services.postgres.ports }}"
test -n "${{ job.services.postgres.ports['80'] }}"
test "${{ job.services.postgres.network }}" = "${{ job.container.network }}"
+9 -12
View File
@@ -4,6 +4,9 @@ env:
jobs:
check:
runs-on: ubuntu-latest
defaults:
run:
shell: ${{ env.MY_SHELL }}
steps:
- shell: ${{ env.MY_SHELL }}
run: |
@@ -12,6 +15,12 @@ jobs:
else
exit 1
fi
- run: |
if [[ -n "$BASH" ]]; then
echo "I'm $BASH!"
else
exit 1
fi
check-container:
runs-on: ubuntu-latest
container: node:24-bookworm-slim
@@ -23,15 +32,3 @@ jobs:
else
exit 1
fi
check-job-default:
runs-on: ubuntu-latest
defaults:
run:
shell: ${{ env.MY_SHELL }}
steps:
- run: |
if [[ -n "$BASH" ]]; then
echo "I'm $BASH!"
else
exit 1
fi
+3 -6
View File
@@ -3,16 +3,13 @@ env:
MY_SHELL: pwsh
jobs:
check:
runs-on: ubuntu-latest
steps:
- shell: ${{ env.MY_SHELL }}
run: |
$PSVersionTable
check-job-default:
runs-on: ubuntu-latest
defaults:
run:
shell: ${{ env.MY_SHELL }}
steps:
- shell: ${{ env.MY_SHELL }}
run: |
$PSVersionTable
- run: |
$PSVersionTable
+9 -12
View File
@@ -4,6 +4,9 @@ env:
jobs:
check:
runs-on: ubuntu-latest
defaults:
run:
shell: ${{ env.MY_SHELL }}
steps:
- shell: ${{ env.MY_SHELL }}
run: |
@@ -12,6 +15,12 @@ jobs:
else
exit 1
fi
- run: |
if [ -z ${BASH+x} ]; then
echo "I'm sh!"
else
exit 1
fi
check-container:
runs-on: ubuntu-latest
container: alpine:latest
@@ -23,15 +32,3 @@ jobs:
else
exit 1
fi
check-job-default:
runs-on: ubuntu-latest
defaults:
run:
shell: ${{ env.MY_SHELL }}
steps:
- run: |
if [ -z ${BASH+x} ]; then
echo "I'm sh!"
else
exit 1
fi
-14
View File
@@ -1,14 +0,0 @@
name: conclusion
on: push
jobs:
check:
runs-on: ubuntu-latest
steps:
- id: first
run: exit 0
- id: second
continue-on-error: true
run: exit 1
- run: echo '${{ steps.first.conclusion }}' | grep 'success'
- run: echo '${{ steps.second.conclusion }}' | grep 'success'
-14
View File
@@ -1,14 +0,0 @@
name: outcome
on: push
jobs:
check:
runs-on: ubuntu-latest
steps:
- id: first
run: exit 0
- id: second
continue-on-error: true
run: exit 1
- run: echo '${{ steps.first.outcome }}' | grep 'success'
- run: echo '${{ steps.second.outcome }}' | grep 'failure'
+17
View File
@@ -0,0 +1,17 @@
name: steps context
on: push
jobs:
check:
runs-on: ubuntu-latest
steps:
- id: first
run: exit 0
- id: second
continue-on-error: true
run: exit 1
- run: |
test '${{ steps.first.conclusion }}' = success
test '${{ steps.second.conclusion }}' = success
test '${{ steps.first.outcome }}' = success
test '${{ steps.second.outcome }}' = failure