package markup
import (
"regexp"
"strings"
"testing"
)
const emailSample = "> quoted *line*\n\n" +
"| a | b |\n|---|--:|\n| 1 | 2 |\n\n" +
"```go\nfmt.Println(1)\n```\n\n" +
" and [rel](/r/alpha/wiki/Home) and [top](#top)\n\n" +
"> [!WARNING]\n> careful\n"
func mailOf(t *testing.T, src string) string {
t.Helper()
got, ok := EmailHTML([]byte(src), nil, func(p string) string { return "https://forge.example" + p })
if !ok {
t.Fatal("EmailHTML fell back")
}
return string(got)
}
func TestEmailHTMLIsSelfContained(t *testing.T) {
got := mailOf(t, emailSample)
for _, banned := range []string{"<style", "<img", "<svg", "<script", "md-"} {
if strings.Contains(got, banned) {
t.Errorf("html carries %q", banned)
}
}
for _, m := range regexp.MustCompile(`href="([^"]*)"`).FindAllStringSubmatch(got, -1) {
if !strings.HasPrefix(m[1], "https://") {
t.Errorf("non-absolute href %q", m[1])
}
}
for _, want := range []string{
`href="https://cdn.example/x.png"`,
`href="https://forge.example/r/alpha/wiki/Home"`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %s", want)
}
}
for _, tag := range []string{"<blockquote style=", "<table style=", "<th style=", "<td style=", "<pre style=", "<code style=", "<p style=", "<a style="} {
if !regexp.MustCompile(strings.Replace(tag, " style=", `[^>]* style=`, 1)).MatchString(got) {
t.Errorf("no inline style on %s", tag)
}
}
}
func TestEmailHTMLFallsBackOverTheCap(t *testing.T) {
big := strings.Repeat("word ", EmailHTMLMax/4)
if _, ok := EmailHTML([]byte(big), nil, func(p string) string { return p }); ok {
t.Fatal("oversized html was kept")
}
}
func TestEmailHTMLKeepsTypographicPunctuation(t *testing.T) {
const want = "\u201cseen, empty\u201d \u2018x\u2019 a \u2014 b \u2013 c \u2026 \u2192"
got := mailOf(t, "record "+want+" now")
if !strings.Contains(got, want) {
t.Errorf("punctuation changed: %s", got)
}
}
type mailRefs struct{ ticketResolver }
func (mailRefs) ResolveToken(t RefToken) (string, bool) {
if t.Kind == RefLanding && t.ID == "12" {
return "/r/q/landing/a1b2c3d4e5f6", true
}
return "", false
}
func (mailRefs) DescribeLanding(t RefToken) (LandingRef, bool) {
if t.Kind == RefLanding && t.ID == "12" {
return LandingRef{Number: 12, State: "accepted"}, true
}
return LandingRef{}, false
}
// References in mail resolve like the page's and carry their state as a
// small PNG and its label: no SVG (mail drops it), no hover card, no
// hook attributes, every address absolute.
func TestEmailHTMLRendersReferencesLikeThePage(t *testing.T) {
src := "landed !12, fixed 54dc6a4148, also 508fd44ab0, see [[Design]], unknown !13"
got, ok := EmailHTML([]byte(src), mailRefs{}, func(p string) string { return "https://forge.example" + p })
if !ok {
t.Fatal("fell back")
}
h := string(got)
for _, want := range []string{
`href="https://forge.example/r/q/landing/a1b2c3d4e5f6"`,
`href="https://forge.example/r/q/ticket/54dc6a414800aa"`,
`href="https://forge.example/r/q/ticket/508fd44ab000bb"`,
`href="https://forge.example/wiki/Design"`,
`src="https://forge.example/icons/landing-accepted.png" alt="landing 12, accepted"`,
`src="https://forge.example/icons/ticket-closed.png" alt="ticket 231, closed"`,
`src="https://forge.example/icons/ticket-open.png" alt="ticket 232, open"`,
`>12</a>`, `>231</a>`, `>232</a>`, "unknown !13",
} {
if !strings.Contains(h, want) {
t.Errorf("missing %s in %s", want, h)
}
}
for _, banned := range []string{"<svg", "data-ref", "data-ic", "aria-label", "tref-state", ">!12<"} {
if strings.Contains(h, banned) {
t.Errorf("carries %q", banned)
}
}
if regexp.MustCompile(`<a [^>]*title=`).MatchString(h) {
t.Error("a reference keeps its hover title")
}
if strings.Count(h, "<img") != 3 {
t.Errorf("%d images, want one per resolved icon reference", strings.Count(h, "<img"))
}
}
type stateRefs struct {
fakeResolver
ticket string
lstate string
}
func (s stateRefs) ResolveToken(t RefToken) (string, bool) {
return "/r/q/" + t.Kind + "/x", t.Kind == RefLanding || t.Kind == RefTicket
}
func (s stateRefs) DescribeTicket(t RefToken) (TicketRef, bool) {
return TicketRef{ID: "x", URL: "/r/q/ticket/x", Number: 7, State: s.ticket}, true
}
func (s stateRefs) DescribeLanding(t RefToken) (LandingRef, bool) {
return LandingRef{Number: 9, State: s.lstate}, true
}
// Every state is its own icon and label, on the page and in the mail,
// and none leaves a state word beside the link.
func TestEveryRefStateHasItsIconOnThePageAndInMail(t *testing.T) {
type row struct{ src, key, label, symbol, tone string }
var rows []row
for state, symbol := range map[string]string{"open": "i-tkt-open", "review": "i-tkt-open", "closed": "i-tkt-done"} {
rows = append(rows, row{"ticket #7", "ticket-" + state, "ticket 7, " + state, symbol, ""})
}
// Landings wear the landing glyph in the state's own tone, so they differ
// from tickets (circles) and from each other.
for state := range map[string]string{"open": "", "accepted": "", "rejected": ""} {
symbol := "i-landing"
rows = append(rows, row{"!9", "landing-" + state, "landing 9, " + state, symbol, ""})
tone := map[string]string{"open": "g-open", "accepted": "g-done", "rejected": "g-rejected"}[state]
rows[len(rows)-1].tone = tone
}
for _, r := range rows {
res := stateRefs{ticket: strings.TrimPrefix(strings.Split(r.key, "-")[1], ""), lstate: strings.Split(r.key, "-")[1]}
page, _ := Render([]byte(r.src), res)
for _, want := range []string{`data-ic="` + r.key + `"`, `aria-label="` + r.label + `"`, `href="#` + r.symbol + `"`} {
if !strings.Contains(string(page), want) {
t.Errorf("%s page lacks %s: %s", r.key, want, page)
}
}
mail, _ := EmailHTML([]byte(r.src), res, func(p string) string { return "https://f.example" + p })
if !strings.Contains(string(mail), `src="https://f.example/icons/`+r.key+`.png" alt="`+r.label+`"`) {
t.Errorf("%s mail lacks its icon: %s", r.key, mail)
}
if r.tone != "" && !strings.Contains(string(page), r.tone) {
t.Errorf("%s page lacks its tone %s", r.key, r.tone)
}
for _, word := range []string{"Open</span>", "Fixed</span>", "tref-state"} {
if strings.Contains(string(page), word) || strings.Contains(string(mail), word) {
t.Errorf("%s: state text %q remains", r.key, word)
}
}
}
}
// An icon and its number never separate across a line: the unit is
// nowrap on the page and, as an inline style, in mail. Words the author
// wrote as the link text may still wrap.
func TestRefIconAndNumberAreOneUnbreakableUnit(t *testing.T) {
res := stateRefs{ticket: "closed", lstate: "accepted"}
const src = "ticket #7 and !9 and [the long words the author wrote for the ticket](/r/q/ticket/x)"
anchor := regexp.MustCompile(`(?s)<a [^>]*data-ic="[^"]*"[^>]*>.*?</a>`)
page, _ := Render([]byte(src), res)
for _, a := range anchor.FindAllString(string(page), -1) {
words := strings.Contains(a, "the long words")
if got := strings.Contains(a, "ref-nw"); got == words {
t.Errorf("page anchor nowrap=%v, words=%v: %s", got, words, a)
}
}
mail, _ := EmailHTML([]byte(src), res, func(p string) string { return "https://f.example" + p })
n := 0
for _, a := range regexp.MustCompile(`(?s)<a [^>]*>.*?</a>`).FindAllString(string(mail), -1) {
words := strings.Contains(a, "the long words")
wrap := strings.Contains(a, "white-space:nowrap")
if wrap == words {
t.Errorf("mail anchor nowrap=%v, words=%v: %s", wrap, words, a)
}
if wrap {
n++
}
}
if n != 2 {
t.Errorf("%d nowrap units in mail, want 2", n)
}
}