From 89af9e2f01898aee042bee47f86e8ecb830480b4 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 6 Dec 2025 11:12:41 -0800 Subject: [PATCH] some improvements --- modules/renderplugin/manifest_test.go | 82 +++++++++++ services/renderplugin/service.go | 10 +- tests/integration/render_plugin_test.go | 188 ++++++++++++++++++++++++ web_src/js/features/file-view.test.ts | 26 ++++ web_src/js/features/file-view.ts | 2 +- 5 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 modules/renderplugin/manifest_test.go create mode 100644 tests/integration/render_plugin_test.go create mode 100644 web_src/js/features/file-view.test.ts diff --git a/modules/renderplugin/manifest_test.go b/modules/renderplugin/manifest_test.go new file mode 100644 index 0000000000..1168f60fc7 --- /dev/null +++ b/modules/renderplugin/manifest_test.go @@ -0,0 +1,82 @@ +package renderplugin + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManifestNormalizeDefaults(t *testing.T) { + manifest := Manifest{ + SchemaVersion: SupportedManifestVersion, + ID: " Example.Plugin ", + Name: " Demo Plugin ", + Version: " 1.0.0 ", + Description: "test", + Entry: "", + FilePatterns: []string{" *.TXT ", "README.md", ""}, + } + + require.NoError(t, manifest.Normalize()) + assert.Equal(t, "example.plugin", manifest.ID) + assert.Equal(t, "render.js", manifest.Entry) + assert.Equal(t, []string{"*.TXT", "README.md"}, manifest.FilePatterns) +} + +func TestManifestNormalizeErrors(t *testing.T) { + base := Manifest{ + SchemaVersion: SupportedManifestVersion, + ID: "example", + Name: "demo", + Version: "1.0", + Entry: "render.js", + FilePatterns: []string{"*.md"}, + } + + tests := []struct { + name string + mutate func(m *Manifest) + message string + }{ + {"missing schema version", func(m *Manifest) { m.SchemaVersion = 0 }, "schemaVersion is required"}, + {"unsupported schema", func(m *Manifest) { m.SchemaVersion = SupportedManifestVersion + 1 }, "not supported"}, + {"invalid id", func(m *Manifest) { m.ID = "bad id" }, "manifest id"}, + {"missing name", func(m *Manifest) { m.Name = "" }, "name is required"}, + {"missing version", func(m *Manifest) { m.Version = "" }, "version is required"}, + {"no patterns", func(m *Manifest) { m.FilePatterns = nil }, "at least one file pattern"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + m := base + tt.mutate(&m) + err := m.Normalize() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.message) + }) + } +} + +func TestLoadManifest(t *testing.T) { + dir := t.TempDir() + manifestJSON := `{ + "schemaVersion": 1, + "id": "Example", + "name": "Example", + "version": "2.0.0", + "description": "demo", + "entry": "render.js", + "filePatterns": ["*.txt", "*.md"] + }` + path := filepath.Join(dir, "manifest.json") + require.NoError(t, os.WriteFile(path, []byte(manifestJSON), 0o644)) + + manifest, err := LoadManifest(dir) + require.NoError(t, err) + assert.Equal(t, "example", manifest.ID) + assert.Equal(t, []string{"*.md", "*.txt"}, manifest.FilePatterns) +} diff --git a/services/renderplugin/service.go b/services/renderplugin/service.go index b797720d28..692fae4e47 100644 --- a/services/renderplugin/service.go +++ b/services/renderplugin/service.go @@ -241,10 +241,16 @@ func replacePluginFiles(identifier, srcDir string) error { func deletePluginFiles(identifier string) error { store := renderplugin.Storage() prefix := renderplugin.ObjectPrefix(identifier) - return store.IterateObjects(prefix, func(path string, obj storage.Object) error { + if err := store.IterateObjects(prefix, func(path string, obj storage.Object) error { _ = obj.Close() return store.Delete(path) - }) + }); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return nil } func uploadPluginDir(identifier, src string) error { diff --git a/tests/integration/render_plugin_test.go b/tests/integration/render_plugin_test.go new file mode 100644 index 0000000000..0268055e1f --- /dev/null +++ b/tests/integration/render_plugin_test.go @@ -0,0 +1,188 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "mime/multipart" + "net/http" + "path" + "strconv" + "strings" + "testing" + + render_model "code.gitea.io/gitea/models/render" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/renderplugin" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderPluginLifecycle(t *testing.T) { + defer tests.PrepareTestEnv(t)() + require.NoError(t, storage.Clean(renderplugin.Storage())) + t.Cleanup(func() { + _ = storage.Clean(renderplugin.Storage()) + }) + + const pluginID = "itest-plugin" + + session := loginUser(t, "user1") + + uploadArchive(t, session, "/-/admin/render-plugins/upload", buildRenderPluginArchive(t, pluginID, "Integration Plugin", "1.0.0")) + flash := expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "installed") + row := requireRenderPluginRow(t, session, pluginID) + assert.Equal(t, "1.0.0", row.Version) + assert.False(t, row.Enabled) + + postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/enable", row.ID)) + flash = expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "enabled") + row = requireRenderPluginRow(t, session, pluginID) + assert.True(t, row.Enabled) + + metas := fetchRenderPluginMetadata(t) + require.Len(t, metas, 1) + assert.Equal(t, pluginID, metas[0].ID) + assert.Contains(t, metas[0].EntryURL, "render.js") + MakeRequest(t, NewRequest(t, "GET", metas[0].EntryURL), http.StatusOK) + + uploadArchive(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/upgrade", row.ID), buildRenderPluginArchive(t, pluginID, "Integration Plugin", "2.0.0")) + flash = expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "upgraded") + row = requireRenderPluginRow(t, session, pluginID) + assert.Equal(t, "2.0.0", row.Version) + + postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/disable", row.ID)) + flash = expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "disabled") + row = requireRenderPluginRow(t, session, pluginID) + assert.False(t, row.Enabled) + require.Len(t, fetchRenderPluginMetadata(t), 0) + + postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/delete", row.ID)) + flash = expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "deleted") + unittest.AssertNotExistsBean(t, &render_model.Plugin{Identifier: pluginID}) + _, err := renderplugin.Storage().Stat(renderplugin.ObjectPath(pluginID, "render.js")) + assert.Error(t, err) + require.Nil(t, findRenderPluginRow(t, session, pluginID)) +} + +func postPluginAction(t *testing.T, session *TestSession, path string) { + req := NewRequestWithValues(t, "POST", path, map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + }) + session.MakeRequest(t, req, http.StatusSeeOther) +} + +func uploadArchive(t *testing.T, session *TestSession, path string, archive []byte) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("_csrf", GetUserCSRFToken(t, session))) + part, err := writer.CreateFormFile("plugin", "plugin.zip") + require.NoError(t, err) + _, err = part.Write(archive) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + req := NewRequestWithBody(t, "POST", path, bytes.NewReader(body.Bytes())) + req.Header.Set("Content-Type", writer.FormDataContentType()) + session.MakeRequest(t, req, http.StatusSeeOther) +} + +func buildRenderPluginArchive(t *testing.T, id, name, version string) []byte { + manifest := fmt.Sprintf(`{ + "schemaVersion": 1, + "id": %q, + "name": %q, + "version": %q, + "description": "integration test plugin", + "entry": "render.js", + "filePatterns": ["*.itest"] +}`, id, name, version) + + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + file, err := zipWriter.Create("manifest.json") + require.NoError(t, err) + _, err = file.Write([]byte(manifest)) + require.NoError(t, err) + + file, err = zipWriter.Create("render.js") + require.NoError(t, err) + _, err = file.Write([]byte("export default {render(){}};")) + require.NoError(t, err) + require.NoError(t, zipWriter.Close()) + return buf.Bytes() +} + +func fetchRenderPluginMetadata(t *testing.T) []renderplugin.Metadata { + resp := MakeRequest(t, NewRequest(t, "GET", "/assets/render-plugins/index.json"), http.StatusOK) + var metas []renderplugin.Metadata + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &metas)) + return metas +} + +func expectFlashSuccess(t *testing.T, session *TestSession) *middleware.Flash { + flash := session.GetCookieFlashMessage() + require.NotNil(t, flash, "expected flash message") + require.Empty(t, flash.ErrorMsg) + return flash +} + +type renderPluginRow struct { + ID int64 + Identifier string + Version string + Enabled bool +} + +func requireRenderPluginRow(t *testing.T, session *TestSession, identifier string) *renderPluginRow { + row := findRenderPluginRow(t, session, identifier) + require.NotNil(t, row, "plugin %s not found", identifier) + return row +} + +func findRenderPluginRow(t *testing.T, session *TestSession, identifier string) *renderPluginRow { + resp := session.MakeRequest(t, NewRequest(t, "GET", "/-/admin/render-plugins"), http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + var result *renderPluginRow + doc.Find("table tbody tr").EachWithBreak(func(_ int, s *goquery.Selection) bool { + cols := s.Find("td") + if cols.Length() < 6 { + return true + } + idText := strings.TrimSpace(cols.Eq(1).Text()) + if idText != identifier { + return true + } + link := cols.Eq(5).Find("a[href]").First() + href, _ := link.Attr("href") + id, err := strconv.ParseInt(path.Base(href), 10, 64) + if err != nil { + return true + } + version := strings.TrimSpace(cols.Eq(2).Text()) + enabled := cols.Eq(4).Find(".ui.green").Length() > 0 + result = &renderPluginRow{ + ID: id, + Identifier: idText, + Version: version, + Enabled: enabled, + } + return false + }) + return result +} diff --git a/web_src/js/features/file-view.test.ts b/web_src/js/features/file-view.test.ts new file mode 100644 index 0000000000..b2d9dd224a --- /dev/null +++ b/web_src/js/features/file-view.test.ts @@ -0,0 +1,26 @@ +import {Buffer} from 'node:buffer'; +import {describe, expect, it, vi} from 'vitest'; +import {decodeHeadChunk} from './file-view.ts'; + +describe('decodeHeadChunk', () => { + it('returns null when input is empty', () => { + expect(decodeHeadChunk(null)).toBeNull(); + expect(decodeHeadChunk('')).toBeNull(); + }); + + it('decodes base64 content into a Uint8Array', () => { + const data = 'Gitea Render Plugin'; + const encoded = Buffer.from(data, 'utf-8').toString('base64'); + const decoded = decodeHeadChunk(encoded); + expect(decoded).not.toBeNull(); + expect(new TextDecoder().decode(decoded!)).toBe(data); + }); + + it('logs and returns null for invalid input', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = decodeHeadChunk('%invalid-base64%'); + expect(result).toBeNull(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 3f76ed1519..6b9b7c214b 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -11,7 +11,7 @@ const plugins: FileRenderPlugin[] = []; let pluginsInitialized = false; let pluginsInitPromise: Promise | null = null; -function decodeHeadChunk(value: string | null): Uint8Array | null { +export function decodeHeadChunk(value: string | null): Uint8Array | null { if (!value) return null; try { const binary = window.atob(value);