| 1 | //go:build browser |
| 2 | |
| 3 | package www_test |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/chromedp/cdproto/accessibility" |
| 14 | "github.com/chromedp/cdproto/cdp" |
| 15 | "github.com/chromedp/cdproto/emulation" |
| 16 | "github.com/chromedp/chromedp" |
| 17 | xhtml "golang.org/x/net/html" |
| 18 | |
| 19 | "amadan.net/rastrillo/fiesta/internal/markup" |
| 20 | "amadan.net/rastrillo/fiesta/internal/rowrule" |
| 21 | "amadan.net/rastrillo/fiesta/internal/www" |
| 22 | ) |
| 23 | |
| 24 | // landingRows are the landing page's own bars, which hold its two |
| 25 | // menus. The page lays them out itself rather than with a Fiesta row, |
| 26 | // so it names them to the Row Rule's check by class. |
| 27 | var landingRows = map[string]string{ |
| 28 | "site-nav": "the top bar: the page's links, the language menu and Get started", |
| 29 | "site-themebar": "the theme bar: the theme buttons and the colour-scheme menu", |
| 30 | } |
| 31 | |
| 32 | // rowWidths are a 320px phone, the 390px phone the bar first wrapped |
| 33 | // on, and a desktop. |
| 34 | var rowWidths = []int64{320, 390, 1280} |
| 35 | |
| 36 | // menuLinesJS measures every menu outside another menu's panel: the |
| 37 | // row it sits in, and how many of that row's other children share a |
| 38 | // line with its summary. A child with no box, or a 1px one like a |
| 39 | // visually hidden label, is not on the line. Then the theme bar, which |
| 40 | // must be one line: each piece it shows, with its top and bottom. |
| 41 | const menuLinesJS = `((rows) => { |
| 42 | const seen = r => r.width > 1 && r.height > 1; |
| 43 | 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(""); |
| 44 | const menus = []; |
| 45 | for (const d of document.querySelectorAll("details[rst-dropdown]")) { |
| 46 | if (d.parentElement.closest("[rst-dropdown-menu]")) continue; |
| 47 | const s = d.querySelector(":scope > summary").getBoundingClientRect(); |
| 48 | const row = d.parentElement.closest(rows.map(c => "." + c).join(",")); |
| 49 | const others = row ? [...row.children].filter(c => !c.contains(d)).map(c => c.getBoundingClientRect()).filter(seen) : []; |
| 50 | 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}); |
| 51 | } |
| 52 | const bar = document.querySelector(".site-themebar"); |
| 53 | const parts = bar ? [bar.querySelector(".site-themelabel"), ...bar.querySelectorAll("[data-site-theme]"), bar.querySelector("[rst-scheme] > summary")].filter(e => e) : []; |
| 54 | const shown = parts.map(e => ({Text: e.textContent.trim(), R: e.getBoundingClientRect()})).filter(p => seen(p.R)); |
| 55 | return JSON.stringify({Menus: menus, VW: document.documentElement.clientWidth, Bar: bar ? bar.getBoundingClientRect().width : 0, |
| 56 | Parts: shown.map(p => ({Text: p.Text, Top: p.R.top, Bottom: p.R.bottom, Width: p.R.width}))}); |
| 57 | })` |
| 58 | |
| 59 | type menuLine struct { |
| 60 | Menu, Row string |
| 61 | Shown bool |
| 62 | Beside, Others int |
| 63 | Top, Bottom float64 |
| 64 | } |
| 65 | |
| 66 | type barPart struct { |
| 67 | Text string |
| 68 | Top, Bottom, Width float64 |
| 69 | } |
| 70 | |
| 71 | type menuLines struct { |
| 72 | Menus []menuLine |
| 73 | VW, Bar float64 |
| 74 | Parts []barPart |
| 75 | } |
| 76 | |
| 77 | // oneLine reports whether the parts share a line: some height that |
| 78 | // every one of them spans. |
| 79 | func oneLine(parts []barPart) bool { |
| 80 | if len(parts) == 0 { |
| 81 | return false |
| 82 | } |
| 83 | top, bottom := parts[0].Top, parts[0].Bottom |
| 84 | for _, p := range parts[1:] { |
| 85 | top, bottom = max(top, p.Top), min(bottom, p.Bottom) |
| 86 | } |
| 87 | return top < bottom-1 |
| 88 | } |
| 89 | |
| 90 | // axName is the accessible name Chromium gives the element sel finds. |
| 91 | func axName(t *testing.T, ctx context.Context, sel string) string { |
| 92 | t.Helper() |
| 93 | var nodes []*cdp.Node |
| 94 | mustRun(t, ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)) |
| 95 | var name string |
| 96 | mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { |
| 97 | ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c) |
| 98 | if err != nil { |
| 99 | return err |
| 100 | } |
| 101 | if len(ax) > 0 && ax[0].Name != nil { |
| 102 | return json.Unmarshal(ax[0].Name.Value, &name) |
| 103 | } |
| 104 | return nil |
| 105 | })) |
| 106 | return name |
| 107 | } |
| 108 | |
| 109 | // TestTheLandingKeepsTheRowRule: the landing page keeps the Row Rule |
| 110 | // that DESIGN.md sets and the gallery's examples are held to, in every |
| 111 | // language the site builds, Arabic right to left, at each width in |
| 112 | // rowWidths. Each menu sits in a row, by the same check the examples |
| 113 | // pass; each shares a line with something else in that row, which only |
| 114 | // a browser at that width can say; and the theme bar keeps all it |
| 115 | // shows, its label, its three buttons and the scheme menu, on one line. |
| 116 | // The bar wrapping the scheme menu onto a line of its own on a phone is |
| 117 | // the shape the rule forbids, on the page that sells Fiesta. A phone has |
| 118 | // room for the bar only with its words out of sight, so the buttons' |
| 119 | // group and the scheme menu are held to their names at every width: |
| 120 | // hidden from sight, the words must still be read out. |
| 121 | func TestTheLandingKeepsTheRowRule(t *testing.T) { |
| 122 | rig, _ := siteRig(t) |
| 123 | ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) |
| 124 | defer cancel() |
| 125 | rowsJS, err := json.Marshal(func() []string { |
| 126 | var classes []string |
| 127 | for c := range landingRows { |
| 128 | classes = append(classes, c) |
| 129 | } |
| 130 | return classes |
| 131 | }()) |
| 132 | if err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | for _, lang := range www.Languages { |
| 136 | mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { |
| 137 | return emulation.SetDeviceMetricsOverride(rowWidths[0], 800, 1, false).Do(c) |
| 138 | }), chromedp.Navigate(rig.Origin+pageURL(lang)), chromedp.WaitReady("body"), |
| 139 | chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise), |
| 140 | soon(".site-themebar")) |
| 141 | var got, dir, page string |
| 142 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got), chromedp.Evaluate(`document.documentElement.dir`, &dir), |
| 143 | chromedp.Evaluate(`document.documentElement.outerHTML`, &page)) |
| 144 | if got != lang || dir != www.Dir(lang) { |
| 145 | t.Fatalf("%s: the page is in %q, written %q; want %s, %s", pageURL(lang), got, dir, lang, www.Dir(lang)) |
| 146 | } |
| 147 | respelled, _ := markup.Respell([]byte(page)) |
| 148 | doc, err := xhtml.Parse(strings.NewReader(string(respelled))) |
| 149 | if err != nil { |
| 150 | t.Fatalf("%s: the page does not parse: %v", lang, err) |
| 151 | } |
| 152 | for _, msg := range rowrule.Breaches(doc, landingRows) { |
| 153 | t.Errorf("%s: %s", lang, msg) |
| 154 | } |
| 155 | words, err := www.Strings(siteSource(t).FS(), lang) |
| 156 | if err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | for _, w := range rowWidths { |
| 160 | mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { |
| 161 | return emulation.SetDeviceMetricsOverride(w, 800, 1, false).Do(c) |
| 162 | })) |
| 163 | var m menuLines |
| 164 | at(t, ctx, menuLinesJS+"("+string(rowsJS)+")", &m) |
| 165 | where := fmt.Sprintf("%s at %d", lang, w) |
| 166 | // A page whose menus the walk cannot find passes whatever |
| 167 | // the rule says. |
| 168 | if len(m.Menus) != 2 || len(m.Parts) < 4 { |
| 169 | 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)) |
| 170 | } |
| 171 | for _, menu := range m.Menus { |
| 172 | switch { |
| 173 | case !menu.Shown: |
| 174 | t.Errorf("%s: %s is not shown", where, menu.Menu) |
| 175 | case menu.Row == "": |
| 176 | t.Errorf("%s: %s is in none of the page's rows", where, menu.Menu) |
| 177 | case menu.Beside == 0: |
| 178 | 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) |
| 179 | } |
| 180 | } |
| 181 | if !oneLine(m.Parts) { |
| 182 | var parts []string |
| 183 | for _, p := range m.Parts { |
| 184 | parts = append(parts, fmt.Sprintf("%q %.0f to %.0f, %.0fpx wide", p.Text, p.Top, p.Bottom, p.Width)) |
| 185 | } |
| 186 | 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 ")) |
| 187 | } |
| 188 | for _, c := range []struct{ sel, key string }{ |
| 189 | {".site-themebar [rst-seg-tabs]", "theme.label"}, |
| 190 | {"[rst-scheme] > summary", "scheme.label"}, |
| 191 | } { |
| 192 | if got := axName(t, ctx, c.sel); got != words[c.key] { |
| 193 | t.Errorf("%s: %s is named %q; want %q, its %s", where, c.sel, got, words[c.key], c.key) |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | if t.Failed() { |
| 199 | var rows []string |
| 200 | for row, why := range landingRows { |
| 201 | rows = append(rows, "."+row+" ("+why+")") |
| 202 | } |
| 203 | t.Logf("each menu belongs on a line in one of these rows: %s", strings.Join(rows, ", ")) |
| 204 | } |
| 205 | } |
| 206 | |