// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package print import ( "bytes" "encoding/json" "testing" "time" "gitea.dev/sdk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) func TestActionRunsListEmpty(t *testing.T) { // Test with empty runs - should not panic defer func() { if r := recover(); r != nil { t.Errorf("ActionRunsList panicked with empty list: %v", r) } }() require.NoError(t, ActionRunsList([]*gitea.ActionWorkflowRun{}, "")) } func TestActionRunsListWithData(t *testing.T) { runs := []*gitea.ActionWorkflowRun{ { ID: 1, Status: "success", DisplayTitle: "Test Workflow", HeadBranch: "main", Event: "push", StartedAt: time.Now().Add(-1 * time.Hour), CompletedAt: time.Now().Add(-30 * time.Minute), }, { ID: 2, Status: "in_progress", Path: ".gitea/workflows/test.yml", HeadBranch: "feature", Event: "pull_request", StartedAt: time.Now().Add(-10 * time.Minute), }, } // Test that it doesn't panic with real data defer func() { if r := recover(); r != nil { t.Errorf("ActionRunsList panicked with data: %v", r) } }() require.NoError(t, ActionRunsList(runs, "")) } func TestActionRunsListJSONHasStableFields(t *testing.T) { startedAt := time.Date(2026, time.August, 2, 10, 0, 0, 0, time.UTC) runs := []*gitea.ActionWorkflowRun{{ ID: 7, Status: "in_progress", DisplayTitle: "CI", Path: ".gitea/workflows/ci.yml", HeadBranch: "main", HeadSha: "abc123", Event: "push", StartedAt: startedAt, CompletedAt: time.Unix(0, 0).UTC(), }} out := captureStdout(t, func() { require.NoError(t, ActionRunsList(runs, "json")) }) var rows []map[string]string require.NoError(t, json.Unmarshal([]byte(out), &rows)) require.Len(t, rows, 1) assert.Equal(t, "abc123", rows[0]["head_sha"]) assert.Equal(t, ".gitea/workflows/ci.yml", rows[0]["path"]) assert.Equal(t, "in_progress", rows[0]["status"]) assert.Equal(t, "", rows[0]["conclusion"]) assert.Equal(t, "2026-08-02T10:00:00Z", rows[0]["started_at"]) assert.Equal(t, "", rows[0]["completed_at"]) assert.NotContains(t, rows[0]["duration"], "-") } func TestActionRunsListEmptyJSONIsValid(t *testing.T) { out := captureStdout(t, func() { require.NoError(t, ActionRunsList(nil, "json")) }) var rows []map[string]string require.NoError(t, json.Unmarshal([]byte(out), &rows)) assert.Empty(t, rows) } func TestActionRunsListHumanColumnsRemainCompatible(t *testing.T) { out := captureStdout(t, func() { require.NoError(t, ActionRunsList([]*gitea.ActionWorkflowRun{{ ID: 1, Status: "completed", Conclusion: "success", DisplayTitle: "CI", }}, "table")) }) assert.Contains(t, out, "WORKFLOW") assert.NotContains(t, out, "CONCLUSION") assert.NotContains(t, out, "HEAD SHA") } func TestActionRunDetails(t *testing.T) { run := &gitea.ActionWorkflowRun{ ID: 123, RunNumber: 42, Status: "success", Conclusion: "success", DisplayTitle: "Build and Test", Path: ".gitea/workflows/ci.yml", HeadBranch: "main", Event: "push", HeadSha: "abc123def456", StartedAt: time.Now().Add(-2 * time.Hour), CompletedAt: time.Now().Add(-1 * time.Hour), RunAttempt: 1, Actor: &gitea.User{ UserName: "testuser", }, HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/123", } // Test that it doesn't panic defer func() { if r := recover(); r != nil { t.Errorf("ActionRunDetails panicked: %v", r) } }() ActionRunDetails(run) } func TestActionRunViewMachineOutputIsSingleDocument(t *testing.T) { startedAt := time.Date(2026, time.August, 2, 10, 0, 0, 0, time.UTC) completedAt := startedAt.Add(5 * time.Minute) run := &gitea.ActionWorkflowRun{ ID: 123, RunNumber: 42, Status: "completed", Conclusion: "failure", DisplayTitle: "CI", Path: ".gitea/workflows/ci.yml", HeadBranch: "main", HeadSha: "abc123", Event: "push", StartedAt: startedAt, CompletedAt: completedAt, RunAttempt: 2, } jobs := []*gitea.ActionWorkflowJob{{ ID: 456, Name: "test", Status: "completed", Conclusion: "failure", HeadSha: "abc123", StartedAt: startedAt, CompletedAt: completedAt, RunnerName: "runner-1", }} for _, output := range []string{"json", "yaml"} { t.Run(output, func(t *testing.T) { buf := &bytes.Buffer{} require.NoError(t, fprintActionRunView(buf, run, jobs, output)) var document struct { Run struct { HeadSHA string `json:"head_sha" yaml:"head_sha"` Path string `json:"path" yaml:"path"` Status string `json:"status" yaml:"status"` Conclusion string `json:"conclusion" yaml:"conclusion"` StartedAt string `json:"started_at" yaml:"started_at"` CompletedAt string `json:"completed_at" yaml:"completed_at"` } `json:"run" yaml:"run"` Jobs []struct { Conclusion string `json:"conclusion" yaml:"conclusion"` } `json:"jobs" yaml:"jobs"` } if output == "json" { require.NoError(t, json.Unmarshal(buf.Bytes(), &document)) } else { require.NoError(t, yaml.Unmarshal(buf.Bytes(), &document)) } assert.Equal(t, "abc123", document.Run.HeadSHA) assert.Equal(t, ".gitea/workflows/ci.yml", document.Run.Path) assert.Equal(t, "completed", document.Run.Status) assert.Equal(t, "failure", document.Run.Conclusion) assert.Equal(t, "2026-08-02T10:00:00Z", document.Run.StartedAt) assert.Equal(t, "2026-08-02T10:05:00Z", document.Run.CompletedAt) require.Len(t, document.Jobs, 1) assert.Equal(t, "failure", document.Jobs[0].Conclusion) }) } } func TestActionRunViewNormalizesEpochCompletion(t *testing.T) { startedAt := time.Date(2026, time.August, 2, 10, 0, 0, 0, time.UTC) buf := &bytes.Buffer{} require.NoError(t, fprintActionRunView(buf, &gitea.ActionWorkflowRun{ ID: 1, Status: "in_progress", StartedAt: startedAt, CompletedAt: time.Unix(0, 0).UTC(), }, nil, "json")) var document actionRunViewOutput require.NoError(t, json.Unmarshal(buf.Bytes(), &document)) assert.Empty(t, document.Run.CompletedAt) assert.NotContains(t, document.Run.Duration, "-") assert.Empty(t, document.Jobs) } func TestActionRunViewRejectsFlatStructuredFormats(t *testing.T) { run := &gitea.ActionWorkflowRun{ID: 1} for _, output := range []string{"csv", "tsv"} { t.Run(output, func(t *testing.T) { err := ActionRunView(run, nil, output) require.ErrorContains(t, err, "use json or yaml") }) } } func TestActionWorkflowJobsListEmpty(t *testing.T) { // Test with empty jobs - should not panic defer func() { if r := recover(); r != nil { t.Errorf("ActionWorkflowJobsList panicked with empty list: %v", r) } }() require.NoError(t, ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, "")) } func TestActionWorkflowJobsListWithData(t *testing.T) { jobs := []*gitea.ActionWorkflowJob{ { ID: 1, Name: "build", Status: "success", RunnerName: "runner-1", StartedAt: time.Now().Add(-30 * time.Minute), CompletedAt: time.Now().Add(-20 * time.Minute), }, { ID: 2, Name: "test", Status: "in_progress", RunnerName: "runner-2", StartedAt: time.Now().Add(-5 * time.Minute), }, } // Test that it doesn't panic with real data defer func() { if r := recover(); r != nil { t.Errorf("ActionWorkflowJobsList panicked with data: %v", r) } }() require.NoError(t, ActionWorkflowJobsList(jobs, "")) } func TestActionWorkflowJobsListHumanColumnsRemainCompatible(t *testing.T) { out := captureStdout(t, func() { require.NoError(t, ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{{ ID: 1, Name: "test", Status: "completed", Conclusion: "success", }}, "table")) }) assert.Contains(t, out, "RUNNER") assert.NotContains(t, out, "CONCLUSION") assert.NotContains(t, out, "HEAD SHA") } func TestActionWorkflowsListEmpty(t *testing.T) { defer func() { if r := recover(); r != nil { t.Errorf("ActionWorkflowsList panicked with empty list: %v", r) } }() require.NoError(t, ActionWorkflowsList([]*gitea.ActionWorkflow{}, "")) } func TestActionWorkflowsListWithData(t *testing.T) { workflows := []*gitea.ActionWorkflow{ { ID: "1", Name: "CI", Path: ".gitea/workflows/ci.yml", State: "active", }, { ID: "2", Name: "Deploy", Path: ".gitea/workflows/deploy.yml", State: "disabled_manually", }, } defer func() { if r := recover(); r != nil { t.Errorf("ActionWorkflowsList panicked with data: %v", r) } }() require.NoError(t, ActionWorkflowsList(workflows, "")) } func TestActionWorkflowDetails(t *testing.T) { wf := &gitea.ActionWorkflow{ ID: "1", Name: "CI Pipeline", Path: ".gitea/workflows/ci.yml", State: "active", HTMLURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml", BadgeURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml/badge.svg", CreatedAt: time.Now().Add(-24 * time.Hour), UpdatedAt: time.Now().Add(-1 * time.Hour), } defer func() { if r := recover(); r != nil { t.Errorf("ActionWorkflowDetails panicked: %v", r) } }() ActionWorkflowDetails(wf) } func TestActionWorkflowDispatchResult(t *testing.T) { details := &gitea.RunDetails{ WorkflowRunID: 42, HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/42", } defer func() { if r := recover(); r != nil { t.Errorf("ActionWorkflowDispatchResult panicked: %v", r) } }() ActionWorkflowDispatchResult(details) } func TestActionWorkflowDispatchResultNil(t *testing.T) { defer func() { if r := recover(); r != nil { t.Errorf("ActionWorkflowDispatchResult panicked with nil: %v", r) } }() ActionWorkflowDispatchResult(nil) } func TestFormatDurationMinutes(t *testing.T) { now := time.Now() tests := []struct { name string started time.Time completed time.Time expected string }{ { name: "zero started", started: time.Time{}, completed: now, expected: "", }, { name: "30 seconds", started: now.Add(-30 * time.Second), completed: now, expected: "30s", }, { name: "5 minutes", started: now.Add(-5 * time.Minute), completed: now, expected: "5m", }, { name: "in progress (no completed)", started: now.Add(-1 * time.Hour), completed: time.Time{}, expected: "1h0m", }, { name: "2 hours 30 minutes", started: now.Add(-150 * time.Minute), completed: now, expected: "2h30m", }, { name: "completion before start", started: now, completed: now.Add(-time.Hour), expected: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { result := formatDurationMinutes(test.started, test.completed) if result != test.expected { t.Errorf("formatDurationMinutes() = %q, want %q", result, test.expected) } }) } }