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 "}}} } 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": "& patch", "RenderedContent": "

Safe review

"}} }) 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 & \"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": "%s %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", "", 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", "", template.HTML("safe"))); got != "<unsafe> safe" { 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("%[2]s", "x?a=1&b=2", template.HTML("safe"))); got != "safe" { 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{{.Subject}}") 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) != "Hello world" { 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": "hello"}}, "zh-CN": {"test": {"activate": "你好"}}}, 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("")) { 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") } } }