Files
gitea-tea/internal/services/action_logs_test.go
T
bircni 9f69a89537 feat(run): show runs like gh, watch them live, and download artifacts
run list prints a table with a status icon, the workflow name, title,
branch, event, elapsed time and age, and the total from the server; runs
carry workflow, workflow_path and run_number in JSON. run view shows the
run's state and trigger, its jobs with their steps and durations, the
next steps and the run's URL; --json includes the jobs, --log prints
their logs and --web opens the run.

Run commands accept #N and run URLs, and ask for a recent run in a
terminal when the argument is left out. run watch redraws the run on the
alternate screen, then prints it with a summary; an expired --timeout
names the run and suspects a missing runner when no job started.

run cancel no longer asks for confirmation, rerun refuses unfinished runs
up front, reruns a single job with --job and reports the new attempt,
and delete names the run in its prompt. run logs follows several jobs
with --follow, prefixing lines with the job's name, selects jobs by name
or with --failed, and downloads the archive with --output or --dir.
run download extracts the artifacts of a run by name, and artifact list
shows sizes and expiry.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 23:46:26 +02:00

168 lines
6.2 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package services
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/transport"
"github.com/stretchr/testify/require"
)
func TestFollowLogsEmitsOnlyAppendedBytesAndStops(t *testing.T) {
snapshots := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/jobs"):
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`{"jobs":[]}`))
return
}
status := "in_progress"
if snapshots >= 2 {
status = "completed"
}
_, _ = fmt.Fprintf(w, `{"jobs":[{"id":9,"status":%q}]}`, status)
case strings.HasSuffix(r.URL.Path, "/logs"):
snapshots++
if snapshots == 1 {
_, _ = w.Write([]byte("first\n"))
} else {
_, _ = w.Write([]byte("first\nsecond\n"))
}
default:
t.Errorf("unexpected endpoint %s", r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}
var output strings.Builder
err := service.FollowJobLogs(context.Background(), 7, 9, time.Millisecond, func(chunk []byte) error { _, err := output.Write(chunk); return err })
require.NoError(t, err)
require.Equal(t, "first\nsecond\n", output.String())
require.Equal(t, 3, snapshots)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = service.FollowJobLogs(ctx, 7, 9, time.Millisecond, func([]byte) error { t.Fatal("canceled follow emitted logs"); return nil })
require.ErrorIs(t, err, context.Canceled)
}
func TestFollowLogsWaitsForQueuedJobAndRejectsChangedSnapshots(t *testing.T) {
for _, changed := range []bool{false, true} {
t.Run(fmt.Sprint(changed), func(t *testing.T) {
reads := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/jobs"):
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`{"jobs":[]}`))
return
}
status := "queued"
if reads > 0 {
status = "in_progress"
}
if reads > 1 && !changed {
status = "completed"
}
_, _ = fmt.Fprintf(w, `{"jobs":[{"id":9,"run_attempt":1,"status":%q}]}`, status)
case strings.HasSuffix(r.URL.Path, "/logs"):
reads++
if reads == 1 {
w.WriteHeader(http.StatusNotFound)
return
}
if reads > 2 && changed {
_, _ = w.Write([]byte("other\nnew\n"))
return
}
_, _ = w.Write([]byte("first\n"))
}
}))
defer server.Close()
service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}
var output strings.Builder
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := service.FollowJobLogs(ctx, 7, 9, time.Millisecond, func(chunk []byte) error { _, err := output.Write(chunk); return err })
if changed {
require.ErrorContains(t, err, "contents changed")
} else {
require.NoError(t, err)
}
require.Equal(t, "first\n", output.String())
require.Equal(t, 3, reads)
})
}
}
func TestJobsPageMapsJobsAndSendsPagination(t *testing.T) {
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/jobs", r.URL.Path)
require.Equal(t, "20", r.URL.Query().Get("limit"))
require.Equal(t, "3", r.URL.Query().Get("page"))
w.Header().Set("X-Total-Count", "41")
_, _ = w.Write([]byte(`{"total_count":41,"jobs":[{"id":9,"run_attempt":2,"name":"build","status":"completed","conclusion":"success","runner_name":"excluded",
"labels":["ubuntu-latest"],"html_url":"https://gitea.test/owner/repo/actions/runs/7/jobs/9","created_at":"2028-01-01T00:00:00Z",
"started_at":"2028-01-01T00:00:01Z","completed_at":"1970-01-01T00:00:00Z",
"steps":[{"number":1,"name":"Set up job","status":"completed","conclusion":"success","started_at":"2028-01-01T00:00:01Z","completed_at":"2028-01-01T00:00:02Z"}]}]}`))
}))
jobs, err := (Actions{Client: client, Repository: "owner/repo"}).JobsPage(context.Background(), 7, 20, 3)
require.NoError(t, err)
started, stepStarted, stepCompleted := "2028-01-01T00:00:01Z", "2028-01-01T00:00:01Z", "2028-01-01T00:00:02Z"
require.Equal(t, Page[Job]{Items: []Job{{
RunAttempt: 2, ID: 9, Name: "build", Status: "completed", Conclusion: "success", URL: "https://gitea.test/owner/repo/actions/runs/7/jobs/9",
Labels: []string{"ubuntu-latest"}, CreatedAt: "2028-01-01T00:00:00Z", StartedAt: &started,
Steps: []Step{{Number: 1, Name: "Set up job", Status: "completed", Conclusion: "success", StartedAt: &stepStarted, CompletedAt: &stepCompleted}},
}}, Total: 41, Counted: true}, jobs)
}
func TestRunLogsArchiveStreams(t *testing.T) {
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/logs", r.URL.Path)
_, _ = w.Write([]byte("PK archive"))
}))
body, err := (Actions{Client: client, Repository: "owner/repo"}).RunLogsArchive(context.Background(), 7)
require.NoError(t, err)
data, err := io.ReadAll(body)
require.NoError(t, err)
require.NoError(t, body.Close())
require.Equal(t, "PK archive", string(data))
}
func TestWaitForFirstJobPollsUntilJobsExist(t *testing.T) {
polls := 0
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/repos/owner/repo/actions/runs/7/jobs":
polls++
if polls < 3 || r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`{"jobs":[]}`))
return
}
_, _ = w.Write([]byte(`{"jobs":[{"id":11,"name":"build"},{"id":12,"name":"test"}]}`))
default:
t.Errorf("unexpected request %s", r.URL.Path)
w.WriteHeader(http.StatusTeapot)
}
}))
service := Actions{Client: client, Repository: "owner/repo"}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
job, err := service.WaitForFirstJob(ctx, 7, time.Millisecond)
require.NoError(t, err)
require.Equal(t, int64(11), job.ID)
require.Equal(t, "build", job.Name)
_, err = service.WaitForFirstJob(ctx, 7, 0)
require.ErrorContains(t, err, "interval must be positive")
}