diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
index f5daf70f3f..89dd25ca29 100644
--- a/routers/web/devtest/mock_actions.go
+++ b/routers/web/devtest/mock_actions.go
@@ -17,6 +17,7 @@ import (
"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"
@@ -36,6 +37,106 @@ var mockActionsArtifactFiles = map[string][]mockArtifactFile{
Content: "artifact-b report",
},
},
+ "artifact-lcov-coverage": {
+ {
+ Path: "coverage/index.html",
+ Content: `
+
+
+
+ Coverage Report
+
+
+
+ LCOV Coverage Report
+ Generated from mock fixture artifact.
+
+
+
+ | File |
+ Lines |
+ Branches |
+ Functions |
+
+
+
+
+ | web_src/js/components/RepoActionView.vue |
+ 100.0% (7/7) |
+ 75.0% (3/4) |
+ 100.0% (3/3) |
+
+
+ | web_src/js/features/repo-actions.ts |
+ 100.0% (5/5) |
+ n/a |
+ 100.0% (2/2) |
+
+
+
+ Open raw lcov.info
+
+`,
+ },
+ {
+ Path: "coverage/lcov.info",
+ Content: `TN:
+SF:web_src/js/components/RepoActionView.vue
+FN:33,artifactBaseURL
+FN:37,artifactPreviewURL
+FN:41,deleteArtifact
+FNF:3
+FNH:3
+FNDA:9,artifactBaseURL
+FNDA:8,artifactPreviewURL
+FNDA:2,deleteArtifact
+DA:33,9
+DA:34,9
+DA:37,8
+DA:38,8
+DA:41,2
+DA:42,2
+DA:43,2
+LF:7
+LH:7
+BRDA:131,0,0,1
+BRDA:131,0,1,3
+BRDA:140,1,0,1
+BRDA:140,1,1,0
+BRF:4
+BRH:3
+end_of_record
+TN:
+SF:web_src/js/features/repo-actions.ts
+FN:12,loadRunView
+FN:61,fetchRunArtifacts
+FNF:2
+FNH:2
+FNDA:5,loadRunView
+FNDA:5,fetchRunArtifacts
+DA:12,5
+DA:13,5
+DA:14,5
+DA:61,5
+DA:62,5
+LF:5
+LH:5
+BRF:0
+BRH:0
+end_of_record
+`,
+ },
+ {
+ Path: "coverage/summary.txt",
+ Content: "HTML coverage fixture with linked lcov.info and realistic function/line/branch records.",
+ },
+ },
"artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong": {
{
Path: "index.html",
@@ -160,6 +261,11 @@ func MockActionsRunsJobs(ctx *context.Context) {
Size: 1024 * 1024,
Status: "completed",
})
+ resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
+ Name: "artifact-lcov-coverage",
+ Size: 256 * 1024,
+ Status: "completed",
+ })
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
Size: 100 * 1024,
@@ -291,7 +397,7 @@ func MockActionsArtifactDownload(ctx *context.Context) {
return
}
- ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
+ ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip"))
writer := zip.NewWriter(ctx.Resp)
defer writer.Close()
for _, file := range files {
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index 378b0caec5..c39f7c0e38 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -135,6 +135,24 @@ type ArtifactPreviewFile struct {
Selected bool
}
+const (
+ artifactPreviewV4ZipListCacheTTL = 10 * time.Minute
+ artifactPreviewV4ZipListCacheMaxEntries = 128
+)
+
+type artifactPreviewV4ZipListCacheEntry struct {
+ paths []string
+ expiresAt time.Time
+}
+
+var artifactPreviewV4ZipListCache = struct {
+ mu sync.Mutex
+ entries map[string]artifactPreviewV4ZipListCacheEntry
+ order []string
+}{
+ entries: map[string]artifactPreviewV4ZipListCacheEntry{},
+}
+
type readAtBySeeker struct {
rs io.ReadSeeker
mu sync.Mutex
@@ -841,19 +859,84 @@ func listArtifactV4ZipFiles(reader *zip.Reader) ([]string, map[string]*zip.File)
}
func listPreviewPathsForV4Artifact(artifact *actions_model.ActionArtifact) ([]string, error) {
+ if paths, ok := getArtifactPreviewV4ZipListFromCache(artifact); ok {
+ return paths, nil
+ }
+
obj, reader, err := openArtifactV4ZipReader(artifact)
if err != nil {
if errors.Is(err, zip.ErrFormat) {
- return []string{artifactPreviewFallbackPath(artifact)}, nil
+ paths := []string{artifactPreviewFallbackPath(artifact)}
+ setArtifactPreviewV4ZipListCache(artifact, paths)
+ return paths, nil
}
return nil, err
}
defer obj.Close()
paths, _ := listArtifactV4ZipFiles(reader)
+ setArtifactPreviewV4ZipListCache(artifact, paths)
return paths, nil
}
+func artifactPreviewV4ZipListCacheKey(artifact *actions_model.ActionArtifact) string {
+ return strconv.FormatInt(artifact.ID, 10) + ":" + strconv.FormatInt(int64(artifact.UpdatedUnix), 10) + ":" + artifact.StoragePath
+}
+
+func removeArtifactPreviewV4ZipListCacheOrderKey(order []string, key string) []string {
+ for i, current := range order {
+ if current != key {
+ continue
+ }
+ return append(order[:i], order[i+1:]...)
+ }
+ return order
+}
+
+func getArtifactPreviewV4ZipListFromCache(artifact *actions_model.ActionArtifact) ([]string, bool) {
+ key := artifactPreviewV4ZipListCacheKey(artifact)
+
+ artifactPreviewV4ZipListCache.mu.Lock()
+ entry, ok := artifactPreviewV4ZipListCache.entries[key]
+ if !ok {
+ artifactPreviewV4ZipListCache.mu.Unlock()
+ return nil, false
+ }
+ if time.Now().After(entry.expiresAt) {
+ delete(artifactPreviewV4ZipListCache.entries, key)
+ artifactPreviewV4ZipListCache.order = removeArtifactPreviewV4ZipListCacheOrderKey(artifactPreviewV4ZipListCache.order, key)
+ artifactPreviewV4ZipListCache.mu.Unlock()
+ return nil, false
+ }
+ paths := append([]string(nil), entry.paths...)
+ artifactPreviewV4ZipListCache.mu.Unlock()
+ return paths, true
+}
+
+func setArtifactPreviewV4ZipListCache(artifact *actions_model.ActionArtifact, paths []string) {
+ key := artifactPreviewV4ZipListCacheKey(artifact)
+
+ artifactPreviewV4ZipListCache.mu.Lock()
+ defer artifactPreviewV4ZipListCache.mu.Unlock()
+
+ if _, ok := artifactPreviewV4ZipListCache.entries[key]; !ok {
+ artifactPreviewV4ZipListCache.order = append(artifactPreviewV4ZipListCache.order, key)
+ }
+ artifactPreviewV4ZipListCache.entries[key] = artifactPreviewV4ZipListCacheEntry{
+ paths: append([]string(nil), paths...),
+ expiresAt: time.Now().Add(artifactPreviewV4ZipListCacheTTL),
+ }
+
+ for len(artifactPreviewV4ZipListCache.entries) > artifactPreviewV4ZipListCacheMaxEntries && len(artifactPreviewV4ZipListCache.order) > 0 {
+ oldestKey := artifactPreviewV4ZipListCache.order[0]
+ artifactPreviewV4ZipListCache.order = artifactPreviewV4ZipListCache.order[1:]
+ if _, ok := artifactPreviewV4ZipListCache.entries[oldestKey]; !ok {
+ continue
+ }
+ delete(artifactPreviewV4ZipListCache.entries, oldestKey)
+ }
+}
+
func listPreviewPaths(artifacts []*actions_model.ActionArtifact) ([]string, error) {
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
return listPreviewPathsForV4Artifact(artifacts[0])
@@ -1081,7 +1164,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
return
}
- ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
+ ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip"))
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
err := actions.DownloadArtifactV4(ctx.Base, artifacts[0])
if err != nil {
@@ -1093,7 +1176,6 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
// Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend
// Those need to be zipped for download
- ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip"))
zipWriter := zip.NewWriter(ctx.Resp)
defer zipWriter.Close()
diff --git a/routers/web/repo/actions/view_test.go b/routers/web/repo/actions/view_test.go
index 7296ea6849..3455e88a30 100644
--- a/routers/web/repo/actions/view_test.go
+++ b/routers/web/repo/actions/view_test.go
@@ -5,6 +5,7 @@ package actions
import (
"testing"
+ "time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/timeutil"
@@ -45,3 +46,90 @@ func TestConvertToViewModel(t *testing.T) {
}
assert.Equal(t, expectedViewJobs, viewJobSteps)
}
+
+func resetArtifactPreviewV4ZipListCacheForTest() {
+ artifactPreviewV4ZipListCache.mu.Lock()
+ defer artifactPreviewV4ZipListCache.mu.Unlock()
+ artifactPreviewV4ZipListCache.entries = map[string]artifactPreviewV4ZipListCacheEntry{}
+ artifactPreviewV4ZipListCache.order = nil
+}
+
+func TestArtifactPreviewV4ZipListCacheSetGet(t *testing.T) {
+ resetArtifactPreviewV4ZipListCacheForTest()
+
+ artifact := &actions_model.ActionArtifact{
+ ID: 1,
+ UpdatedUnix: timeutil.TimeStamp(2),
+ StoragePath: "artifact/path.zip",
+ }
+ paths := []string{"index.html", "logs/output.txt"}
+ setArtifactPreviewV4ZipListCache(artifact, paths)
+
+ paths[0] = "changed"
+ got, ok := getArtifactPreviewV4ZipListFromCache(artifact)
+ require.True(t, ok)
+ assert.Equal(t, []string{"index.html", "logs/output.txt"}, got)
+
+ got[0] = "changed-again"
+ got2, ok := getArtifactPreviewV4ZipListFromCache(artifact)
+ require.True(t, ok)
+ assert.Equal(t, []string{"index.html", "logs/output.txt"}, got2)
+}
+
+func TestArtifactPreviewV4ZipListCacheExpires(t *testing.T) {
+ resetArtifactPreviewV4ZipListCacheForTest()
+
+ artifact := &actions_model.ActionArtifact{
+ ID: 2,
+ UpdatedUnix: timeutil.TimeStamp(3),
+ StoragePath: "artifact/expired.zip",
+ }
+ key := artifactPreviewV4ZipListCacheKey(artifact)
+
+ artifactPreviewV4ZipListCache.mu.Lock()
+ artifactPreviewV4ZipListCache.entries[key] = artifactPreviewV4ZipListCacheEntry{
+ paths: []string{"expired.txt"},
+ expiresAt: time.Now().Add(-time.Second),
+ }
+ artifactPreviewV4ZipListCache.order = []string{key}
+ artifactPreviewV4ZipListCache.mu.Unlock()
+
+ _, ok := getArtifactPreviewV4ZipListFromCache(artifact)
+ require.False(t, ok)
+
+ artifactPreviewV4ZipListCache.mu.Lock()
+ _, exists := artifactPreviewV4ZipListCache.entries[key]
+ order := append([]string(nil), artifactPreviewV4ZipListCache.order...)
+ artifactPreviewV4ZipListCache.mu.Unlock()
+ assert.False(t, exists)
+ assert.NotContains(t, order, key)
+}
+
+func TestArtifactPreviewV4ZipListCacheEvictsOldest(t *testing.T) {
+ resetArtifactPreviewV4ZipListCacheForTest()
+
+ for i := range artifactPreviewV4ZipListCacheMaxEntries + 1 {
+ artifact := &actions_model.ActionArtifact{
+ ID: int64(i + 1),
+ UpdatedUnix: timeutil.TimeStamp(i + 1),
+ StoragePath: "artifact/cache-entry.zip",
+ }
+ setArtifactPreviewV4ZipListCache(artifact, []string{"file.txt"})
+ }
+
+ oldest := &actions_model.ActionArtifact{
+ ID: 1,
+ UpdatedUnix: timeutil.TimeStamp(1),
+ StoragePath: "artifact/cache-entry.zip",
+ }
+ _, ok := getArtifactPreviewV4ZipListFromCache(oldest)
+ assert.False(t, ok)
+
+ newest := &actions_model.ActionArtifact{
+ ID: artifactPreviewV4ZipListCacheMaxEntries + 1,
+ UpdatedUnix: timeutil.TimeStamp(artifactPreviewV4ZipListCacheMaxEntries + 1),
+ StoragePath: "artifact/cache-entry.zip",
+ }
+ _, ok = getArtifactPreviewV4ZipListFromCache(newest)
+ assert.True(t, ok)
+}
diff --git a/templates/shared/actions/artifact_preview_content.tmpl b/templates/shared/actions/artifact_preview_content.tmpl
index dd16648574..1fc13fca2e 100644
--- a/templates/shared/actions/artifact_preview_content.tmpl
+++ b/templates/shared/actions/artifact_preview_content.tmpl
@@ -7,10 +7,6 @@
min-width: 0;
}
-.artifact-preview-subtitle {
- color: var(--color-text-light-3);
-}
-
.artifact-preview-frame {
width: 100%;
min-height: 70vh;
@@ -22,14 +18,17 @@
diff --git a/tests/integration/actions_artifact_preview_test.go b/tests/integration/actions_artifact_preview_test.go
index 7df2dc708d..def760463c 100644
--- a/tests/integration/actions_artifact_preview_test.go
+++ b/tests/integration/actions_artifact_preview_test.go
@@ -36,6 +36,8 @@ func TestActionsArtifactPreviewSingleFile(t *testing.T) {
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "abc.txt")
assert.Contains(t, resp.Body.String(), "/preview/raw/abc.txt")
+ assert.Contains(t, resp.Body.String(), "sandbox=\"allow-scripts\"")
+ assert.Contains(t, resp.Body.String(), "referrerpolicy=\"no-referrer\"")
req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName())
resp = session.MakeRequest(t, req, http.StatusOK)
@@ -54,7 +56,11 @@ func TestActionsArtifactPreviewMultiFile(t *testing.T) {
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"))
+ req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview?path=%s", repo.FullName(), url.QueryEscape("xyz/def.txt"))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), "/preview/raw/xyz/def.txt")
+
+ req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview/raw/xyz/def.txt", repo.FullName())
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, strings.Repeat("C", 1024), resp.Body.String())
}