diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
index 0fb2a35824..0c76fa6034 100644
--- a/routers/web/devtest/mock_actions.go
+++ b/routers/web/devtest/mock_actions.go
@@ -4,21 +4,132 @@
package devtest
import (
+ "archive/zip"
+ "fmt"
+ "html/template"
+ "io"
mathRand "math/rand/v2"
"net/http"
+ "net/url"
+ "path"
"slices"
"strconv"
"strings"
"time"
actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/routers/web/repo/actions"
"code.gitea.io/gitea/services/context"
)
+type mockArtifactFile struct {
+ Path string
+ Content string
+}
+
+type mockArtifactPreviewTemplateData struct {
+ ArtifactName string
+ Files []mockArtifactPreviewTemplateFile
+ PreviewURL string
+ PreviewRaw string
+ DownloadURL string
+ SelectedPath string
+}
+
+type mockArtifactPreviewTemplateFile struct {
+ Path string
+ Selected bool
+}
+
+var mockActionsArtifactFiles = map[string][]mockArtifactFile{
+ "artifact-b": {
+ {
+ Path: "report.txt",
+ Content: "artifact-b report",
+ },
+ },
+ "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong": {
+ {
+ Path: "index.html",
+ Content: `
+
+
+
+ {{template "repo/header" .}}
+
+
+
+
+
+
+
+
+
+ {{if .SelectedPath}}
+
+ {{else}}
+
{{ctx.Locale.Tr "none"}}
+ {{end}}
+
+
+
+
+
+
+
+{{template "base/footer" .}}
diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl
index 59b5c9cbf9..463e8278af 100644
--- a/templates/repo/actions/view_component.tmpl
+++ b/templates/repo/actions/view_component.tmpl
@@ -31,6 +31,7 @@
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}"
data-locale-download-logs="{{ctx.Locale.Tr "download_logs"}}"
+ data-locale-download-file="{{ctx.Locale.Tr "repo.download_file"}}"
data-locale-logs-always-auto-scroll="{{ctx.Locale.Tr "actions.logs.always_auto_scroll"}}"
data-locale-logs-always-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}"
>
diff --git a/tests/integration/actions_artifact_preview_test.go b/tests/integration/actions_artifact_preview_test.go
new file mode 100644
index 0000000000..c127915f9c
--- /dev/null
+++ b/tests/integration/actions_artifact_preview_test.go
@@ -0,0 +1,94 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/storage"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func overwriteArtifactStorageContent(t *testing.T, artifactID int64, content []byte) {
+ t.Helper()
+ artifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: artifactID})
+ _, err := storage.ActionsArtifacts.Save(artifact.StoragePath, bytes.NewReader(content), int64(len(content)))
+ require.NoError(t, err)
+}
+
+func TestActionsArtifactPreviewSingleFile(t *testing.T) {
+ defer prepareTestEnvActionsArtifacts(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ session := loginUser(t, "user2")
+
+ req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview", repo.FullName())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), "abc.txt")
+ assert.Contains(t, resp.Body.String(), "/preview/raw?path=abc.txt")
+
+ req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, strings.Repeat("A", 1024), resp.Body.String())
+ assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain")
+}
+
+func TestActionsArtifactPreviewMultiFile(t *testing.T) {
+ defer prepareTestEnvActionsArtifacts(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ session := loginUser(t, "user2")
+
+ req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview", repo.FullName())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), "abc.txt")
+ assert.Contains(t, resp.Body.String(), "xyz/def.txt")
+
+ req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview/raw?path=%s", repo.FullName(), url.QueryEscape("xyz/def.txt"))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, strings.Repeat("C", 1024), resp.Body.String())
+}
+
+func TestActionsArtifactPreviewUnsupportedType(t *testing.T) {
+ defer prepareTestEnvActionsArtifacts(t)()
+
+ overwriteArtifactStorageContent(t, 1, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0x00, 0x00, 0x0d})
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ session := loginUser(t, "user2")
+ req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName())
+ session.MakeRequest(t, req, http.StatusUnsupportedMediaType)
+}
+
+func TestActionsArtifactPreviewHTMLSandboxCSP(t *testing.T) {
+ defer prepareTestEnvActionsArtifacts(t)()
+
+ overwriteArtifactStorageContent(t, 1, []byte("