chore: migrate mail themes to shared framework and locked upstream inputs
This commit is contained in:
1 parent
5c0589f6f6
commit
fec3ace600
225 files changed
+4718
-15807
No files matched your search
@@ -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 != "<x> <x> (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><unsafe></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&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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user