package www_test import ( "bytes" "encoding/json" "fmt" "io/fs" "regexp" "strconv" "strings" "testing" "testing/fstest" xhtml "golang.org/x/net/html" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/www" ) // landingDir is a site source holding the real landing/ files (the // template and every catalog) with files overlaid on top. func landingDir(t *testing.T, files map[string]string) fstest.MapFS { t.Helper() return withLanding(t, siteOf(files)) } // withLanding copies the real landing/ files, the template and every // catalog, from the repository's site/ into m, under any file m // already has at that path. Pages needs them for the root page, so // every test of the tree starts from this. func withLanding(t *testing.T, m fstest.MapFS) fstest.MapFS { t.Helper() src := siteSource(t).FS() err := fs.WalkDir(src, "landing", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if _, overlaid := m[p]; overlaid { return nil } data, err := fs.ReadFile(src, p) if err != nil { return err } m[p] = &fstest.MapFile{Data: data} return nil }) if err != nil { t.Fatalf("reading the site's landing/ directory: %v", err) } return m } // english renders the English landing page from the real site source. func english(t *testing.T) []byte { t.Helper() out, err := www.Landing(siteSource(t).FS(), "en") if err != nil { t.Fatalf("landing: %v", err) } return out } // parse parses a page, failed loudly. func parse(t *testing.T, doc []byte) *xhtml.Node { t.Helper() n, err := xhtml.Parse(bytes.NewReader(doc)) if err != nil { t.Fatalf("parsing the page: %v", err) } return n } // attr is one attribute's value, "" when absent. func attr(n *xhtml.Node, key string) string { for _, a := range n.Attr { if a.Key == key { return a.Val } } return "" } // walk calls fn on every element node in document order. func walk(n *xhtml.Node, fn func(*xhtml.Node)) { if n.Type == xhtml.ElementNode { fn(n) } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c, fn) } } // text is the text under n, with the whitespace collapsed. func text(n *xhtml.Node) string { var b strings.Builder var rec func(*xhtml.Node) rec = func(n *xhtml.Node) { if n.Type == xhtml.TextNode { b.WriteString(n.Data) } for c := n.FirstChild; c != nil; c = c.NextSibling { rec(c) } } rec(n) return strings.Join(strings.Fields(b.String()), " ") } // TestTheLandingKeepsTheSpecsOrder: the page's sections carry the // spec's ids, top to bottom in the spec's order, with the footer // after the reasons. Document order is what a reader without CSS // gets, so it is the order that matters. func TestTheLandingKeepsTheSpecsOrder(t *testing.T) { want := []string{"top", "bunting", "headline", "install", "actions", "themes", "sample", "reasons", "footer"} wanted := map[string]bool{} for _, id := range want { wanted[id] = true } var got []string walk(parse(t, english(t)), func(n *xhtml.Node) { if id := attr(n, "id"); wanted[id] { got = append(got, id) } }) if strings.Join(got, " ") != strings.Join(want, " ") { t.Errorf("the landing page's sections are %v in document order; want %v", got, want) } } // urlRef is a quoted root-relative path inside the head's inline // script: the one place a URL can hide from the link and script tags. // The theme's URL is built from pieces there, and the piece that // starts with / is the one that says where it loads from. var urlRef = regexp.MustCompile(`["']/[^"' ]*["']`) // TestTheLandingLinksFiestaByItsPublicURLs: every stylesheet and // script the page loads is /1/..., the public release URL, or the // site's own /static/..., or inline. The design system copies Fiesta's // source; the landing page must not, so it is served what any user // is. The inline script's URLs are held to the same rule, since it is // what writes the theme's link. func TestTheLandingLinksFiestaByItsPublicURLs(t *testing.T) { public := func(u string) bool { return strings.HasPrefix(u, "/1/") || strings.HasPrefix(u, "/static/") } seen := 0 walk(parse(t, english(t)), func(n *xhtml.Node) { switch n.Data { case "link": if attr(n, "rel") != "stylesheet" { return } seen++ if u := attr(n, "href"); !public(u) { t.Errorf("a stylesheet is loaded from %q; the landing page loads Fiesta only by its public URLs", u) } case "script": if src := attr(n, "src"); src != "" { seen++ if !public(src) { t.Errorf("a script is loaded from %q; the landing page loads Fiesta only by its public URLs", src) } return } for _, m := range urlRef.FindAllString(text(n), -1) { seen++ u := strings.Trim(m, `"'`) if !public(u) { t.Errorf("the inline script names %q; the landing page loads Fiesta only by its public URLs", u) } } } }) if seen < 6 { t.Errorf("only %d stylesheet and script URLs were seen; the page loads at least three stylesheets and five scripts", seen) } } // installLink is the one line a user pastes, as the install box shows // it: the whole URL, nothing elided. const installLink = `` // TestTheLinkBoxShowsTheWholeURL: the install section holds the link // tag in a code element, left to right and never translated, with the // whole URL in its text. func TestTheLinkBoxShowsTheWholeURL(t *testing.T) { found := false walk(parse(t, english(t)), func(n *xhtml.Node) { if n.Data != "code" || text(n) != installLink { return } found = true if attr(n, "dir") != "ltr" { t.Errorf("the link box's code has dir=%q; want ltr, so the URL reads left to right in every language", attr(n, "dir")) } if attr(n, "translate") != "no" { t.Errorf("the link box's code has translate=%q; want no", attr(n, "translate")) } inInstall := false for p := n.Parent; p != nil; p = p.Parent { if attr(p, "id") == "install" { inInstall = true } } if !inInstall { t.Error("the link box is not inside the install section") } }) if !found { t.Errorf("no code element on the page reads %s", installLink) } } // jsConstant reads one `NAME = ` from bunting.js, failing when // the script no longer declares it: a constant that moved is a drift // this test can no longer see. func jsConstant(t *testing.T, js, name string) int { t.Helper() m := regexp.MustCompile(`\b` + name + ` = (\d+)\b`).FindStringSubmatch(js) if m == nil { t.Fatalf("bunting.js no longer declares %s = ; the still bunting cannot be held to it", name) } n, _ := strconv.Atoi(m[1]) return n } // TestTheStillBuntingIsTheAnimations: the still SVG in the markup, // which a reader with no JS or under reduced motion sees, hangs the // same bunting bunting.js animates: the same row width and height, // the same number of swags, the same pennants at the same places, // read from the script's own constants. The pennants' colours cycle // in the order the script reads from the markup. func TestTheStillBuntingIsTheAnimations(t *testing.T) { js, err := fs.ReadFile(siteSource(t).FS(), "static/bunting.js") if err != nil { t.Fatal(err) } pitch, per, end := jsConstant(t, string(js), "PITCH"), jsConstant(t, string(js), "PER"), jsConstant(t, string(js), "END") fw, fh, h := jsConstant(t, string(js), "FW"), jsConstant(t, string(js), "FH"), jsConstant(t, string(js), "H") m := regexp.MustCompile(`\bROW = SPAN \* (\d+)\b`).FindStringSubmatch(string(js)) if m == nil { t.Fatal("bunting.js no longer declares ROW = SPAN * ") } swags, _ := strconv.Atoi(m[1]) span := pitch*per + 2*end var svg *xhtml.Node walk(parse(t, english(t)), func(n *xhtml.Node) { if n.Data == "svg" && attr(n, "class") == "site-flags" { svg = n } }) if svg == nil { t.Fatal("the page has no svg.site-flags") } if want := fmt.Sprintf("0 0 %d %d", span*swags, h); attr(svg, "viewBox") != want { t.Errorf("the still bunting's viewBox is %q; the animation's row is %q", attr(svg, "viewBox"), want) } ropes, tacks, flags, defs := 0, 0, 0, 0 translate := regexp.MustCompile(`^translate\((-?[\d.]+) (-?[\d.]+)\)( rotate\(-?[\d.]+\))?$`) point := regexp.MustCompile(`[ML](-?[\d.]+) (-?[\d.]+)`) walk(svg, func(n *xhtml.Node) { switch { case n.Data == "path" && attr(n, "class") == "site-span": // The rope is one path through every node of the hang, as // the canvas draws it; its tacks are the nodes that never // move, one at each span's end, at the rope's own height. ropes++ for _, m := range point.FindAllStringSubmatch(attr(n, "d"), -1) { x, _ := strconv.ParseFloat(m[1], 64) y, _ := strconv.ParseFloat(m[2], 64) if int(x)%span == 0 && x == float64(int(x)) && y == float64(jsConstant(t, string(js), "TOPY")) { tacks++ } } case n.Data == "polygon" && strings.Contains(attr(n, "points"), fmt.Sprintf("0,%d", fh)) && strings.HasPrefix(attr(n, "points"), fmt.Sprintf("-%d,0 %d,0", fw/2, fw/2)): defs++ case n.Data == "g" && attr(n, "class") == "site-flag": k := flags flags++ tm := translate.FindStringSubmatch(attr(n, "transform")) if tm == nil { t.Errorf("pennant %d has transform %q; want a translate, with at most a rotate", k, attr(n, "transform")) return } x, _ := strconv.ParseFloat(tm[1], 64) want := float64((k/per)*span + end + (k%per)*pitch + pitch/2) // Within four units of where it is tied: as the rope sags // it draws a swag's pennants a little toward its middle, // and the picture test holds the exact hang. A pennant out // by a pitch, or a swag out by its span, is still caught. if x < want-4 || x > want+4 { t.Errorf("pennant %d hangs at x=%g; the animation ties it at %g", k, x, want) } if c, _ := strconv.Atoi(attr(n, "data-c")); c != k%6 { t.Errorf("pennant %d is cloth %d; want %d, cycling through the six", k, c, k%6) } } }) if ropes != 1 { t.Errorf("the still bunting has %d rope paths; the canvas draws one rope", ropes) } if tacks != swags+1 { t.Errorf("the still bunting's rope has %d tacks; the animation's %d swags hang between %d", tacks, swags, swags+1) } if flags != swags*per { t.Errorf("the still bunting has %d pennants; the animation hangs %d, %d a swag", flags, swags*per, per) } if defs != 6 { t.Errorf("%d pennant definitions are %d wide and %d tall; want all six", defs, fw, fh) } } // marked renders the landing page from siteDir with every English // string wrapped as ⟦value⟧, so what came from the catalog can be told // from what did not. func marked(t *testing.T, siteDir fstest.MapFS) []byte { t.Helper() real, err := www.Strings(siteSource(t).FS(), "en") if err != nil { t.Fatal(err) } wrapped := map[string]string{} for k, v := range real { wrapped[k] = "⟦" + v + "⟧" } data, err := json.Marshal(wrapped) if err != nil { t.Fatal(err) } siteDir["landing/strings/en.json"] = &fstest.MapFile{Data: data} out, err := www.Landing(siteDir, "en") if err != nil { t.Fatalf("landing: %v", err) } return out } // unmarkedSurfaces lists every text surface on doc that did not come // from the catalog: a text node with something in it, outside // translate="no", script, style and noscript (whose content the parser // keeps as text, and which holds the no-JS stylesheet link, not // words), that has no ⟦; and an aria-label, placeholder, title or alt // attribute without one. func unmarkedSurfaces(t *testing.T, doc []byte) []string { t.Helper() var out []string var rec func(n *xhtml.Node, where string) rec = func(n *xhtml.Node, where string) { switch n.Type { case xhtml.TextNode: if s := strings.TrimSpace(n.Data); s != "" && !strings.Contains(s, "⟦") { out = append(out, where+": text "+strconvQuote(s)) } return case xhtml.ElementNode: if n.Data == "script" || n.Data == "style" || n.Data == "noscript" || attr(n, "translate") == "no" { return } where += "/" + n.Data if id := attr(n, "id"); id != "" { where += "#" + id } for _, a := range n.Attr { switch a.Key { case "aria-label", "placeholder", "title", "alt": if !strings.Contains(a.Val, "⟦") { out = append(out, where+": "+a.Key+"="+strconvQuote(a.Val)) } } } } for c := n.FirstChild; c != nil; c = c.NextSibling { rec(c, where) } } rec(parse(t, doc), "") return out } func strconvQuote(s string) string { if len(s) > 60 { s = s[:60] + "…" } return `"` + s + `"` } // TestEveryTextSurfaceComesFromTheCatalog: rendered with a catalog // whose every value is wrapped in ⟦ ⟧, every text node outside // translate="no", script and style carries the mark, and so does every // aria-label, placeholder, title and alt. The control is the same // template with one hard-coded English word, which the check must // name. func TestEveryTextSurfaceComesFromTheCatalog(t *testing.T) { if got := unmarkedSurfaces(t, marked(t, landingDir(t, nil))); len(got) > 0 { t.Errorf("%d text surfaces did not come from the catalog:\n %s", len(got), strings.Join(got, "\n ")) } src, err := fs.ReadFile(siteSource(t).FS(), "landing/page.html.tmpl") if err != nil { t.Fatal(err) } control := strings.Replace(string(src), "", "

Hello

", 1) if control == string(src) { t.Fatal("the template has no to plant the control after") } got := unmarkedSurfaces(t, marked(t, landingDir(t, map[string]string{"landing/page.html.tmpl": control}))) if len(got) != 1 || !strings.Contains(got[0], `"Hello"`) { t.Errorf("the control, one hard-coded word, was reported as %v; want exactly it", got) } } // pagePath is where a language's landing page lives in the tree: // English at the root, every other language under its code. func pagePath(lang string) string { if lang == "en" { return "index.html" } return lang + "/index.html" } // pageURL is the same page's URL on the site, a directory with a // trailing slash, which is what the hreflang links and the language // menu point at. func pageURL(lang string) string { if lang == "en" { return "/" } return "/" + lang + "/" } // hasScheme reports whether u is an absolute URL, scheme and all. var hasScheme = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`) // TestEveryLanguageHasAPage: Pages writes one landing page per // language in www.Languages, English at index.html and every other // at /index.html, each with its own lang and dir on the html // element and its own catalog's title. The root page is English and // does not redirect. And since every other page lives one directory // down, every URL on it is root-relative, a fragment or absolute: // a document-relative URL would resolve against // and miss. func TestEveryLanguageHasAPage(t *testing.T) { out := pages(t, siteOf(nil)) for _, lang := range www.Languages { doc, ok := out[pagePath(lang)] if !ok { t.Errorf("Pages wrote no %s; the landing page in %s is missing", pagePath(lang), lang) continue } strs, err := www.Strings(siteSource(t).FS(), lang) if err != nil { t.Fatal(err) } var html *xhtml.Node var title string var relative []string walk(parse(t, doc), func(n *xhtml.Node) { switch n.Data { case "html": html = n case "title": title = text(n) case "meta": if strings.EqualFold(attr(n, "http-equiv"), "refresh") { t.Errorf("%s carries a meta refresh; a landing page never redirects", pagePath(lang)) } } for _, a := range n.Attr { if a.Key != "href" && a.Key != "src" && a.Key != "action" { continue } if strings.HasPrefix(a.Val, "/") || strings.HasPrefix(a.Val, "#") || hasScheme.MatchString(a.Val) { continue } relative = append(relative, "<"+n.Data+" "+a.Key+"="+strconvQuote(a.Val)+">") } }) if html == nil { t.Errorf("%s has no html element", pagePath(lang)) continue } if got := attr(html, "lang"); got != lang { t.Errorf("%s has ; want %q", pagePath(lang), got, lang) } if got, want := attr(html, "dir"), www.Dir(lang); got != want { t.Errorf("%s has ; want %q", pagePath(lang), got, want) } if title != strs["title"] { t.Errorf("%s is titled %q; the %s catalog says %q", pagePath(lang), title, lang, strs["title"]) } if len(relative) > 0 { t.Errorf("%s has %d document-relative URLs, which resolve against %s rather than the root:\n %s", pagePath(lang), len(relative), pageURL(lang), strings.Join(relative, "\n ")) } } if _, ok := out["en/index.html"]; ok { t.Error("Pages wrote en/index.html; English lives at the root, not under /en/") } } // endonyms reads each language's name in its own language from // Fiesta's locale-menu example, so the site's menu names languages as // Fiesta does and invents nothing. func endonyms(t *testing.T) map[string]string { t.Helper() doc, err := fs.ReadFile(fiesta.FS(), "examples/locale-menu/twelve-languages-current-highlighted.html") if err != nil { t.Fatalf("reading the locale-menu example: %v", err) } out := map[string]string{} walk(parse(t, doc), func(n *xhtml.Node) { if n.Data == "button" && attr(n, "lang") != "" { out[attr(n, "lang")] = text(n) } }) for _, lang := range www.Languages { if out[lang] == "" { t.Fatalf("the locale-menu example names no %s; the site's menu has nothing to take its name from", lang) } } return out } // TestEveryPageLinksEveryLanguage: every landing page carries a // rel=alternate hreflang link for each of the eleven languages and an // x-default pointing at the root, and its language menu lists the // eleven as links with lang set, named by the locale-menu example's // endonyms, never translated, with the page's own language marked // aria-current and no other. func TestEveryPageLinksEveryLanguage(t *testing.T) { out := pages(t, siteOf(nil)) names := endonyms(t) for _, lang := range www.Languages { doc, ok := out[pagePath(lang)] if !ok { t.Errorf("Pages wrote no %s", pagePath(lang)) continue } alternates := map[string]string{} var menu []*xhtml.Node walk(parse(t, doc), func(n *xhtml.Node) { switch { case n.Data == "link" && attr(n, "rel") == "alternate": if hl := attr(n, "hreflang"); hl != "" { if _, dup := alternates[hl]; dup { t.Errorf("%s has two hreflang=%q links", pagePath(lang), hl) } alternates[hl] = attr(n, "href") } case n.Data == "a" && attr(n, "lang") != "": inMenu := false for p := n.Parent; p != nil; p = p.Parent { if attr(p, "class") == "site-lang" { inMenu = true } } if inMenu { menu = append(menu, n) } } }) // hreflang wants a fully qualified URL, so every one is the // site's origin and the page's path. for _, l := range www.Languages { if got, want := alternates[l], www.Origin+pageURL(l); got != want { t.Errorf("%s links hreflang=%q to %q; want %q", pagePath(lang), l, got, want) } } if got, want := alternates["x-default"], www.Origin+"/"; got != want { t.Errorf("%s links hreflang=x-default to %q; want %q", pagePath(lang), got, want) } if len(alternates) != len(www.Languages)+1 { t.Errorf("%s has %d hreflang links; want %d, the eleven languages and x-default", pagePath(lang), len(alternates), len(www.Languages)+1) } if len(menu) != len(www.Languages) { t.Errorf("%s's language menu lists %d languages; want %d", pagePath(lang), len(menu), len(www.Languages)) } seen := map[string]bool{} var current []string for _, a := range menu { code := attr(a, "lang") seen[code] = true if got, want := attr(a, "href"), pageURL(code); got != want { t.Errorf("%s's menu links %s to %q; want %q", pagePath(lang), code, got, want) } if got, want := text(a), names[code]; got != want { t.Errorf("%s's menu names %s %q; the locale-menu example says %q", pagePath(lang), code, got, want) } // The current page's entry says aria-current="page", and // no other entry carries the attribute at all: a screen // reader announces any value, "false" included. switch cur := attr(a, "aria-current"); { case cur == "page": current = append(current, code) case cur != "": t.Errorf("%s's menu entry for %s has aria-current=%q; the current entry says \"page\" and the rest say nothing", pagePath(lang), code, cur) } noTranslate := false for p := a; p != nil; p = p.Parent { if p.Type == xhtml.ElementNode && attr(p, "translate") == "no" { noTranslate = true } } if !noTranslate { t.Errorf("%s's menu entry for %s is not translate=\"no\"; a language's name stays in that language", pagePath(lang), code) } } for _, l := range www.Languages { if !seen[l] { t.Errorf("%s's language menu has no entry for %s", pagePath(lang), l) } } if len(current) != 1 || current[0] != lang { t.Errorf("%s's menu marks %v aria-current; want %s alone", pagePath(lang), current, lang) } } } // designSystemURL is where the landing page in lang sends a reader who // asks for the design system: the design system's own root for // English, which is the default theme's English Overview, and that // theme's Overview in lang for every other language. func designSystemURL(lang string) string { if lang == "en" { return www.Mount + "/" } return www.Mount + "/day/" + lang + "/" } // TestTheLandingLinksTheDesignSystemInItsLanguage: on the landing page // in each language, "Design system" in the nav and the footer and "See // the design system" under the headline link the design system in the // page's own language: /es/ links /design-system/day/es/, and / links // /design-system/. The links are found by their words, read from the // page's own catalog, so the check names the link a reader sees; and // no link on the page goes anywhere else under the design system, so a // link left on the English root is caught wherever it sits. func TestTheLandingLinksTheDesignSystemInItsLanguage(t *testing.T) { for _, lang := range www.Languages { doc, err := www.Landing(siteSource(t).FS(), lang) if err != nil { t.Fatalf("landing in %s: %v", lang, err) } strs, err := www.Strings(siteSource(t).FS(), lang) if err != nil { t.Fatal(err) } want := designSystemURL(lang) found := map[string]int{} walk(parse(t, doc), func(n *xhtml.Node) { if n.Data != "a" { return } href := attr(n, "href") named := false for _, key := range []string{"nav.design", "cta.see"} { if text(n) != strs[key] { continue } named = true found[key]++ if href != want { t.Errorf("%s: %q links %q; want %q, the design system in the page's language", pageURL(lang), text(n), href, want) } } if !named && (href == www.Mount || strings.HasPrefix(href, www.Mount+"/")) && href != want { t.Errorf("%s: a link reading %q goes to %q; every link to the design system goes to %q", pageURL(lang), text(n), href, want) } }) for key, least := range map[string]int{"nav.design": 2, "cta.see": 1} { if found[key] < least { t.Errorf("%s: %d links read %q (%s); want at least %d", pageURL(lang), found[key], strs[key], key, least) } } } }