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), "