Files
forge/tests/integration/actions_runner_test.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

202 lines
6.4 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
auth_model "github.com/hanzoai/git/models/auth"
runner_module "github.com/hanzoai/forge/modules/actions/runner"
"github.com/hanzoai/git/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockRunner speaks the runner protocol the way a real runner does: one POST per
// operation under /v1/runner, carrying JSON in and JSON out, with its uuid and
// token in headers once it has registered.
type mockRunner struct {
uuid string
token string
}
func newMockRunner() *mockRunner {
return &mockRunner{}
}
// runnerCall invokes one operation and decodes the reply. A non-2xx answer comes
// back as an error carrying the fault body, which is the whole error contract.
func runnerCall[In, Out any](t *testing.T, r *mockRunner, opName string, in *In) (*Out, error) {
t.Helper()
body, err := json.Marshal(in)
require.NoError(t, err)
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, setting.AppURL+"v1/runner/"+opName, bytes.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
if r.uuid != "" {
req.Header.Set("x-runner-uuid", r.uuid)
req.Header.Set("x-runner-token", r.token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s: %s", opName, resp.Status, bytes.TrimSpace(payload))
}
out := new(Out)
require.NoError(t, json.Unmarshal(payload, out))
return out, nil
}
func (r *mockRunner) doRegister(t *testing.T, name, token string, labels []string, ephemeral bool) {
out, err := runnerCall[runner_module.RegisterIn, runner_module.RegisterOut](t, r, "register", &runner_module.RegisterIn{
Name: name,
Token: token,
Version: "mock-runner-version",
Labels: labels,
Ephemeral: ephemeral,
})
assert.NoError(t, err)
if out != nil {
r.uuid, r.token = out.Runner.UUID, out.Runner.Token
}
}
func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, runnerName string, labels []string, ephemeral bool) {
session := loginUser(t, ownerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, http.MethodPost, fmt.Sprintf("/v1/repos/%s/%s/actions/runners/registration-token", ownerName, repoName)).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
registrationToken := DecodeJSON(t, resp, &struct {
Token string `json:"token"`
}{})
r.doRegister(t, runnerName, registrationToken.Token, labels, ephemeral)
}
// task asks for work.
func (r *mockRunner) task(t *testing.T, in *runner_module.TaskIn) (*runner_module.TaskOut, error) {
return runnerCall[runner_module.TaskIn, runner_module.TaskOut](t, r, "task", in)
}
// state reports task progress.
func (r *mockRunner) state(t *testing.T, in *runner_module.StateIn) (*runner_module.StateOut, error) {
return runnerCall[runner_module.StateIn, runner_module.StateOut](t, r, "state", in)
}
// appendLog adds console output.
func (r *mockRunner) appendLog(t *testing.T, in *runner_module.LogIn) (*runner_module.LogOut, error) {
return runnerCall[runner_module.LogIn, runner_module.LogOut](t, r, "log", in)
}
func (r *mockRunner) fetchTask(t *testing.T, timeout ...time.Duration) *runner_module.Task {
task := r.tryFetchTask(t, timeout...)
require.NotNil(t, task, "failed to fetch a task")
return task
}
func (r *mockRunner) fetchNoTask(t *testing.T, timeout ...time.Duration) {
task := r.tryFetchTask(t, timeout...)
require.Nil(t, task, "a task is fetched")
}
const defaultFetchTaskTimeout = 1 * time.Second
func (r *mockRunner) tryFetchTask(t *testing.T, timeout ...time.Duration) *runner_module.Task {
fetchTimeout := defaultFetchTaskTimeout
if len(timeout) > 0 {
fetchTimeout = timeout[0]
}
ddl := time.Now().Add(fetchTimeout)
var task *runner_module.Task
for time.Now().Before(ddl) {
task, _ = r.fetchTaskOnce(t, 0)
if task != nil {
break
}
time.Sleep(200 * time.Millisecond)
}
return task
}
// fetchTaskOnce asks for work once with the given queue version and returns the
// task, if any, along with the version the forge answered with. This is the
// production path: a runner always sends the version it last saw.
func (r *mockRunner) fetchTaskOnce(t *testing.T, queue int64) (*runner_module.Task, int64) {
out, err := r.task(t, &runner_module.TaskIn{Queue: queue})
require.NoError(t, err)
return out.Task, out.Queue
}
type mockTaskOutcome struct {
result runner_module.Result
outputs map[string]string
logLines []runner_module.Line
}
func (r *mockRunner) execTask(t *testing.T, task *runner_module.Task, outcome *mockTaskOutcome) {
for idx, line := range outcome.logLines {
out, err := r.appendLog(t, &runner_module.LogIn{
Task: task.ID,
Index: int64(idx),
Lines: []runner_module.Line{line},
Last: idx == len(outcome.logLines)-1,
})
assert.NoError(t, err)
assert.EqualValues(t, idx+1, out.Ack)
}
stored := make([]string, 0, len(outcome.outputs))
for outputKey, outputValue := range outcome.outputs {
out, err := r.state(t, &runner_module.StateIn{
State: runner_module.State{ID: task.ID, Result: runner_module.Pending},
Outputs: []runner_module.Pair{{Name: outputKey, Value: outputValue}},
})
assert.NoError(t, err)
stored = append(stored, outputKey)
assert.ElementsMatch(t, stored, out.Stored)
}
out, err := r.state(t, &runner_module.StateIn{
State: runner_module.State{ID: task.ID, Result: outcome.result, Stopped: time.Now().UnixNano()},
})
assert.NoError(t, err)
assert.Equal(t, outcome.result, out.State.Result)
}
// valueOf reads one named value out of the ordered list the wire carries.
func valueOf(pairs []runner_module.Pair, name string) string {
for _, p := range pairs {
if p.Name == name {
return p.Value
}
}
return ""
}
// needOf reads what one job this task depends on produced.
func needOf(needs []runner_module.Need, job string) runner_module.Need {
for _, need := range needs {
if need.Job == job {
return need
}
}
return runner_module.Need{}
}
// needOutput reads one output of one job this task depends on.
func needOutput(needs []runner_module.Need, job, name string) string {
return valueOf(needOf(needs, job).Outputs, name)
}