chore: migrate mail themes to shared framework and locked upstream inputs
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s

This commit is contained in:
KenanZhu committed 2026-10-09 18:34:36 +08:00
1 parent 5c0589f6f6
commit fec3ace600
225 files changed
+4718 -15807

No files matched your search

+317
View File
@@ -0,0 +1,317 @@
package preview
import (
"bytes"
"fmt"
"html/template"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea-mail-templates/tools/builder"
"gitea-mail-templates/tools/config"
"gitea-mail-templates/tools/upstream"
"golang.org/x/net/html"
)
type testCommitID string
func (id testCommitID) String() string { return string(id) }
type scenario struct {
id, name string
params map[string]any
}
func branchFixtures(cfg *config.TemplatesConfig) []scenario {
var result []scenario
for id, f := range cfg.Templates {
result = append(result, scenario{id, "default", f.Params})
}
add := func(id, name string, edit func(map[string]any)) {
data := CloneParams(cfg.Templates[id].Params)
edit(data)
result = append(result, scenario{id, name, data})
}
for _, action := range []string{"new", "close", "reopen", "merge", "approve", "reject", "review", "review_dismissed", "ready_for_review"} {
add("default", action, func(d map[string]any) {
d["ActionName"] = action
d["Body"] = ""
d["IsMention"] = true
d["CanReply"] = true
})
}
for _, n := range []int{0, 1, 2} {
add("default", fmt.Sprintf("push-%d", n), func(d map[string]any) {
d["ActionName"] = "push"
d["Body"] = ""
c := d["Comment"].(map[string]any)
commits := make([]any, n)
for i := range commits {
commits[i] = map[string]any{"UserCommit": map[string]any{"GitCommit": map[string]any{"ID": testCommitID("0123456789abcdef0123456789abcdef01234567"), "MessageTitle": "Commit <escaped>"}}}
}
c["Commits"] = commits
})
}
add("default", "force-push", func(d map[string]any) {
d["ActionName"] = "push"
d["Body"] = ""
c := d["Comment"].(map[string]any)
c["IsForcePush"] = true
c["OldCommit"] = "0123456789abcdef"
c["NewCommit"] = "fedcba9876543210"
})
add("default", "review-comments", func(d map[string]any) {
d["ReviewComments"] = []any{map[string]any{"TreePath": "src/example.go", "Patch": "<unsafe>& patch", "RenderedContent": "<p>Safe <strong>review</strong></p>"}}
})
add("assigned", "pull-reply", func(d map[string]any) { d["IsPull"] = true; d["CanReply"] = true })
add("release", "no-downloads-or-note", func(d map[string]any) {
d["DisableDownloadSourceArchives"] = true
r := d["Release"].(map[string]any)
r["Attachments"] = []any{}
r["RenderedNote"] = ""
})
add("workflow_run", "no-jobs", func(d map[string]any) { d["Jobs"] = []any{} })
add("workflow_run", "failure-jobs", func(d map[string]any) {
for _, job := range d["Jobs"].([]any) {
j := job.(map[string]any)
j["StatusClass"] = "status-failure"
j["StatusIconCID"] = "failure"
j["StatusIconAlt"] = "Failure"
}
})
add("activate", "escaped-name", func(d map[string]any) { d["DisplayName"] = "test.example <User> & \"Admin\""; d["Code"] = "a&b= c" })
return result
}
type semantics struct {
Text string
Links, Images []string
}
func mailSemantics(source string, hints ...string) (semantics, error) {
root, err := html.Parse(strings.NewReader(source))
if err != nil {
return semantics{}, err
}
var result semantics
var text []string
var visible func(*html.Node) string
visible = func(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
if n.Type == html.ElementNode && (n.Data == "head" || n.Data == "script" || n.Data == "style") {
return ""
}
var parts []string
for c := n.FirstChild; c != nil; c = c.NextSibling {
parts = append(parts, visible(c))
}
return strings.Join(parts, " ")
}
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "head" {
return
}
if n.Type == html.ElementNode {
for _, a := range n.Attr {
if a.Key == "class" && strings.Contains(" "+a.Val+" ", " framework-extra ") {
return
}
}
if n.Data == "p" {
for _, hint := range hints {
if strings.Join(strings.Fields(visible(n)), " ") == strings.Join(strings.Fields(hint), " ") {
return
}
}
}
}
if n.Type == html.TextNode {
text = append(text, n.Data)
}
if n.Type == html.ElementNode {
if n.Data == "a" {
for _, a := range n.Attr {
if a.Key == "href" {
result.Links = append(result.Links, a.Val+" | "+strings.Join(strings.Fields(visible(n)), " "))
}
}
}
if n.Data == "img" {
var attrs []string
for _, a := range n.Attr {
if a.Key == "src" || a.Key == "alt" {
attrs = append(attrs, a.Key+"="+a.Val)
}
}
result.Images = append(result.Images, strings.Join(attrs, " | "))
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
result.Text = strings.Join(strings.Fields(strings.Join(text, " ")), " ")
return result, nil
}
// Run every discovered language and theme against stock Gitea for both normal
// fixtures and branches that historically broke custom mail overrides.
func TestAllThemeContentParity(t *testing.T) {
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
if err := AlignConfig(s, cfg); err != nil {
t.Fatal(err)
}
packages, err := builder.BuildAll(filepath.Join("..", "..", "themes"), s, nil)
if err != nil {
t.Fatal(err)
}
// Shared mode has the same parity guarantee although migrated themes are framed.
shared, err := builder.Build(s, &builder.Theme{Name: "test", Mode: "shared", CSS: "a { color:#2563eb; }"})
if err != nil {
t.Fatal(err)
}
packages["shared-test"] = shared
for _, lang := range s.Languages() {
t.Run(lang, func(t *testing.T) {
for _, fixture := range branchFixtures(cfg) {
entry := strings.Join(cfg.Templates[fixture.id].Path, "/")
stock, err := RenderMail(s, s.Templates, entry, fixture.params, lang)
if err != nil {
t.Fatalf("stock %s/%s: %v", fixture.id, fixture.name, err)
}
hint := Locale{Lang: lang, Catalog: s.Locales[lang], English: s.Locales["en-US"]}.TrString("mail.link_not_working_do_paste")
want, err := mailSemantics(stock.HTML, hint)
if err != nil {
t.Fatal(err)
}
for theme, p := range packages {
mail, err := RenderMail(s, builder.EffectiveTemplates(s, p), entry, fixture.params, lang)
if err != nil {
t.Fatalf("%s/%s/%s: %v", theme, fixture.id, fixture.name, err)
}
got, err := mailSemantics(mail.HTML, hint)
if err != nil {
t.Fatal(err)
}
if stock.Subject != mail.Subject || !reflect.DeepEqual(want, got) {
t.Fatalf("content changed for %s/%s/%s", theme, fixture.id, fixture.name)
}
if fixture.id == "release" && fixture.name == "default" && (!strings.Contains(mail.HTML, "1.5 KiB") || !strings.Contains(mail.HTML, "release-notes.txt")) {
t.Fatal("release attachment regression")
}
if fixture.name == "push-1" && !strings.Contains(mail.HTML, "/commit/0123456789abcdef0123456789abcdef01234567") {
t.Fatal("push link regression")
}
}
}
})
}
}
func TestLocaleFormatting(t *testing.T) {
var problems []error
base := map[string]string{"repeat": "%[1]s %[1]s (%[2]d)", "mixed": "%[2]s %s %%", "html": "<b>%s</b> %s", "one": "one", "many": "many", "fallback": "English", "broken": "%d"}
l := Locale{Lang: "en-US", Catalog: base, English: base, Problems: &problems}
if got := string(l.Tr("repeat", "<x>", 7)); got != "&lt;x&gt; &lt;x&gt; (7)" {
t.Fatal(got)
}
if got := l.TrString("mixed", "ignored", "second", "third"); got != "second third %" {
t.Fatal(got)
}
if got := string(l.Tr("html", "<unsafe>", template.HTML("<i>safe</i>"))); got != "<b>&lt;unsafe&gt;</b> <i>safe</i>" {
t.Fatal(got)
}
if got := l.TrString("repeat", []any{"slice", int64(2)}); got != "slice slice (2)" {
t.Fatal(got)
}
for _, tc := range []struct {
lang string
n any
want string
}{{"en-US", 0, "many"}, {"en-US", 1, "one"}, {"en-US", 2, "many"}, {"zh-CN", 2, "one"}, {"fr-FR", 0, "one"}, {"ru-RU", 21, "one"}, {"ru-RU", 11, "many"}, {"en-US", float64(1), "many"}} {
l.Lang = tc.lang
if got := string(l.TrN(tc.n, "one", "many")); got != tc.want {
t.Fatalf("%v: %s", tc, got)
}
}
l.Catalog = map[string]string{}
if got := l.TrString("fallback"); got != "English" {
t.Fatal(got)
}
if len(problems) != 0 {
t.Fatal(problems)
}
l.Tr("missing")
l.Tr("broken", "text")
if len(problems) != 2 {
t.Fatal("missing/formatting errors were not surfaced")
}
if got := string(HTMLFormat("<a href='%[1]s'>%[2]s</a>", "x?a=1&b=2", template.HTML("<b>safe</b>"))); got != "<a href='x?a=1&amp;b=2'><b>safe</b></a>" {
t.Fatal(got)
}
}
func TestSubjectSplitAndMissingFixture(t *testing.T) {
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
files := builder.EffectiveTemplates(s, nil)
files["mail/test.tmpl"] = []byte(" {{.Subject}}\n------------\n<body>{{.Subject}}</body>")
mail, err := RenderMail(s, files, "mail/test.tmpl", map[string]any{"Subject": " Hello world "}, "en-US")
if err != nil {
t.Fatal(err)
}
if mail.Subject != "Hello world" || strings.TrimSpace(mail.HTML) != "<body>Hello world</body>" {
t.Fatal(mail)
}
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
delete(cfg.Templates, "release")
if AlignConfig(s, cfg) == nil {
t.Fatal("missing fixture accepted")
}
if _, err := RenderMail(s, s.Templates, "mail/repo/release.tmpl", map[string]any{}, "en-US"); err == nil {
t.Fatal("missing context accepted")
}
}
func TestPreviewBundles(t *testing.T) {
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{"en-US": {"test": {"activate": "<body>hello</body>"}}, "zh-CN": {"test": {"activate": "<body>你好</body>"}}}, Languages: []string{"en-US", "zh-CN"}, Themes: []string{"test"}, Registry: map[string]map[string]string{}, Upstream: "v28.0.0"}
output := filepath.Join(t.TempDir(), "rendered.js")
if err := WriteRenderedJS(result, output); err != nil {
t.Fatal(err)
}
manifest, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(manifest, []byte("<body>")) {
t.Fatal("manifest embeds mail payload")
}
for _, lang := range result.Languages {
data, err := os.ReadFile(filepath.Join(filepath.Dir(output), "rendered", lang+".js"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("window.__MAIL_PREVIEW_DATA__")) {
t.Fatal("not a file:// script bundle")
}
}
}