//go:build browser package www_test import ( "context" "encoding/json" "fmt" "strings" "testing" "time" "github.com/chromedp/cdproto/accessibility" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/chromedp" xhtml "golang.org/x/net/html" "amadan.net/rastrillo/fiesta/internal/markup" "amadan.net/rastrillo/fiesta/internal/rowrule" "amadan.net/rastrillo/fiesta/internal/www" ) // landingRows are the landing page's own bars, which hold its two // menus. The page lays them out itself rather than with a Fiesta row, // so it names them to the Row Rule's check by class. var landingRows = map[string]string{ "site-nav": "the top bar: the page's links, the language menu and Get started", "site-themebar": "the theme bar: the theme buttons and the colour-scheme menu", } // rowWidths are a 320px phone, the 390px phone the bar first wrapped // on, and a desktop. var rowWidths = []int64{320, 390, 1280} // menuLinesJS measures every menu outside another menu's panel: the // row it sits in, and how many of that row's other children share a // line with its summary. A child with no box, or a 1px one like a // visually hidden label, is not on the line. Then the theme bar, which // must be one line: each piece it shows, with its top and bottom. const menuLinesJS = `((rows) => { const seen = r => r.width > 1 && r.height > 1; const name = e => e.tagName.toLowerCase() + [...e.attributes].filter(a => a.name.startsWith("rst-") || a.name === "class").map(a => a.name === "class" ? "." + a.value.trim().split(/\s+/).join(".") : "[" + a.name + "]").join(""); const menus = []; for (const d of document.querySelectorAll("details[rst-dropdown]")) { if (d.parentElement.closest("[rst-dropdown-menu]")) continue; const s = d.querySelector(":scope > summary").getBoundingClientRect(); const row = d.parentElement.closest(rows.map(c => "." + c).join(",")); const others = row ? [...row.children].filter(c => !c.contains(d)).map(c => c.getBoundingClientRect()).filter(seen) : []; menus.push({Menu: name(d), Row: row ? name(row) : "", Shown: seen(s), Beside: others.filter(r => r.top < s.bottom - 1 && s.top < r.bottom - 1).length, Others: others.length, Top: s.top, Bottom: s.bottom}); } const bar = document.querySelector(".site-themebar"); const parts = bar ? [bar.querySelector(".site-themelabel"), ...bar.querySelectorAll("[data-site-theme]"), bar.querySelector("[rst-scheme] > summary")].filter(e => e) : []; const shown = parts.map(e => ({Text: e.textContent.trim(), R: e.getBoundingClientRect()})).filter(p => seen(p.R)); return JSON.stringify({Menus: menus, VW: document.documentElement.clientWidth, Bar: bar ? bar.getBoundingClientRect().width : 0, Parts: shown.map(p => ({Text: p.Text, Top: p.R.top, Bottom: p.R.bottom, Width: p.R.width}))}); })` type menuLine struct { Menu, Row string Shown bool Beside, Others int Top, Bottom float64 } type barPart struct { Text string Top, Bottom, Width float64 } type menuLines struct { Menus []menuLine VW, Bar float64 Parts []barPart } // oneLine reports whether the parts share a line: some height that // every one of them spans. func oneLine(parts []barPart) bool { if len(parts) == 0 { return false } top, bottom := parts[0].Top, parts[0].Bottom for _, p := range parts[1:] { top, bottom = max(top, p.Top), min(bottom, p.Bottom) } return top < bottom-1 } // axName is the accessible name Chromium gives the element sel finds. func axName(t *testing.T, ctx context.Context, sel string) string { t.Helper() var nodes []*cdp.Node mustRun(t, ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)) var name string mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c) if err != nil { return err } if len(ax) > 0 && ax[0].Name != nil { return json.Unmarshal(ax[0].Name.Value, &name) } return nil })) return name } // TestTheLandingKeepsTheRowRule: the landing page keeps the Row Rule // that DESIGN.md sets and the gallery's examples are held to, in every // language the site builds, Arabic right to left, at each width in // rowWidths. Each menu sits in a row, by the same check the examples // pass; each shares a line with something else in that row, which only // a browser at that width can say; and the theme bar keeps all it // shows, its label, its three buttons and the scheme menu, on one line. // The bar wrapping the scheme menu onto a line of its own on a phone is // the shape the rule forbids, on the page that sells Fiesta. A phone has // room for the bar only with its words out of sight, so the buttons' // group and the scheme menu are held to their names at every width: // hidden from sight, the words must still be read out. func TestTheLandingKeepsTheRowRule(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) defer cancel() rowsJS, err := json.Marshal(func() []string { var classes []string for c := range landingRows { classes = append(classes, c) } return classes }()) if err != nil { t.Fatal(err) } for _, lang := range www.Languages { mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { return emulation.SetDeviceMetricsOverride(rowWidths[0], 800, 1, false).Do(c) }), chromedp.Navigate(rig.Origin+pageURL(lang)), chromedp.WaitReady("body"), chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise), soon(".site-themebar")) var got, dir, page string mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got), chromedp.Evaluate(`document.documentElement.dir`, &dir), chromedp.Evaluate(`document.documentElement.outerHTML`, &page)) if got != lang || dir != www.Dir(lang) { t.Fatalf("%s: the page is in %q, written %q; want %s, %s", pageURL(lang), got, dir, lang, www.Dir(lang)) } respelled, _ := markup.Respell([]byte(page)) doc, err := xhtml.Parse(strings.NewReader(string(respelled))) if err != nil { t.Fatalf("%s: the page does not parse: %v", lang, err) } for _, msg := range rowrule.Breaches(doc, landingRows) { t.Errorf("%s: %s", lang, msg) } words, err := www.Strings(siteSource(t).FS(), lang) if err != nil { t.Fatal(err) } for _, w := range rowWidths { mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { return emulation.SetDeviceMetricsOverride(w, 800, 1, false).Do(c) })) var m menuLines at(t, ctx, menuLinesJS+"("+string(rowsJS)+")", &m) where := fmt.Sprintf("%s at %d", lang, w) // A page whose menus the walk cannot find passes whatever // the rule says. if len(m.Menus) != 2 || len(m.Parts) < 4 { t.Fatalf("%s: found %d menus and %d theme bar pieces; want the language and scheme menus, and at least the three buttons and the menu", where, len(m.Menus), len(m.Parts)) } for _, menu := range m.Menus { switch { case !menu.Shown: t.Errorf("%s: %s is not shown", where, menu.Menu) case menu.Row == "": t.Errorf("%s: %s is in none of the page's rows", where, menu.Menu) case menu.Beside == 0: t.Errorf("%s: %s sits on a line of its own (%.0f to %.0f), beside none of the other %d things in %s", where, menu.Menu, menu.Top, menu.Bottom, menu.Others, menu.Row) } } if !oneLine(m.Parts) { var parts []string for _, p := range m.Parts { parts = append(parts, fmt.Sprintf("%q %.0f to %.0f, %.0fpx wide", p.Text, p.Top, p.Bottom, p.Width)) } t.Errorf("%s: the theme bar (%.0fpx in a %.0fpx page) is more than one line:\n %s", where, m.Bar, m.VW, strings.Join(parts, "\n ")) } for _, c := range []struct{ sel, key string }{ {".site-themebar [rst-seg-tabs]", "theme.label"}, {"[rst-scheme] > summary", "scheme.label"}, } { if got := axName(t, ctx, c.sel); got != words[c.key] { t.Errorf("%s: %s is named %q; want %q, its %s", where, c.sel, got, words[c.key], c.key) } } } } if t.Failed() { var rows []string for row, why := range landingRows { rows = append(rows, "."+row+" ("+why+")") } t.Logf("each menu belongs on a line in one of these rows: %s", strings.Join(rows, ", ")) } }