//go:build browser // The side nav as a reader meets it: a column of links beside the // panel's content where the panel is wide, and a row of links above it // where the panel is narrow, whatever the window's width, since a // settings card on a desktop is as narrow as a phone; links that go // somewhere with no script; and links that look and answer like the // rows of a menu, focus ring included, on a page with no rst-page. package check import ( "context" "fmt" "maps" "math" "slices" "strings" "testing" "time" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/harness" ) // sideNavExample is the side-nav example's one state (its id and the // routes its links point at) and that state as a document, its assets // flattened beside the page as the sizing mux serves them. func sideNavExample(t *testing.T) (example.State, string) { t.Helper() entry, ok := manifest(t).Entry("side-nav") if !ok || len(entry.States) != 1 { t.Fatal("examples.json has no side-nav entry with one state") } state := entry.States[0] doc, err := example.Document(fiesta.FS(), "side-nav", state.ID) if err != nil { t.Fatal(err) } return state, parityLinks(doc) } // inPanel is doc with its body's markup inside a panel of the given // width, the way a page puts the side nav in a card. func inPanel(t *testing.T, doc, width string) string { t.Helper() if !strings.Contains(doc, "\n") || !strings.Contains(doc, "\n") { t.Fatal("the side-nav document has no lines to wrap in a panel") } doc = strings.Replace(doc, "\n", ``+"\n"+`
`+"\n", 1) return strings.Replace(doc, "\n", "\n
\n", 1) } // longLabels is doc with each link's label made long, so four of them // cannot fit a narrow panel side by side. func longLabels(t *testing.T, doc string) string { t.Helper() for _, l := range []string{"General", "Members", "Billing", "Notifications"} { was := ">" + l + "" if !strings.Contains(doc, was) { t.Fatalf("the side-nav document has no link labelled %s", l) } doc = strings.Replace(doc, was, ">"+l+" for the whole workspace", 1) } return doc } // sideGeometry is what the layout tests read: the panel's content box // (the body's, for the page with no panel), the nav's box with its // scroll and client widths and its four border widths, the content's // box, each link's box and how many lines its label takes, and the // document's scroll and client widths. type sideGeometry struct { Panel, Nav, Content sideBox Links []sideBox Lines []int NavScroll, NavClient float64 Start, End, Top, Bottom string DocScroll, DocClient float64 } type sideBox struct{ Left, Right, Top, Bottom float64 } const sideGeometryJS = `(() => { const side = document.querySelector("[rst-side]"), nav = side.querySelector("[rst-side-nav]"), content = side.querySelector(":scope > :not([rst-side-nav])"), ns = getComputedStyle(nav); const b = (el) => { const r = el.getBoundingClientRect(); return {Left: r.left, Right: r.right, Top: r.top, Bottom: r.bottom}; }; const p = side.parentElement, pr = p.getBoundingClientRect(), ps = getComputedStyle(p), px = (v) => parseFloat(v) || 0; const rtl = getComputedStyle(side).direction === "rtl"; return JSON.stringify({ Panel: {Left: pr.left + px(ps.borderLeftWidth) + px(ps.paddingLeft), Right: pr.right - px(ps.borderRightWidth) - px(ps.paddingRight), Top: pr.top, Bottom: pr.bottom}, Nav: b(nav), Content: b(content), Links: [...nav.querySelectorAll("a")].map(b), Lines: [...nav.querySelectorAll("a")].map((a) => { const r = document.createRange(); r.selectNodeContents(a); return new Set([...r.getClientRects()].map((q) => Math.round(q.top))).size; }), NavScroll: nav.scrollWidth, NavClient: nav.clientWidth, Start: rtl ? ns.borderRightWidth : ns.borderLeftWidth, End: rtl ? ns.borderLeftWidth : ns.borderRightWidth, Top: ns.borderTopWidth, Bottom: ns.borderBottomWidth, DocScroll: document.documentElement.scrollWidth, DocClient: document.documentElement.clientWidth}); })()` // readSide opens path at the given viewport width and reads it. func readSide(t *testing.T, ctx context.Context, origin, path string, width int64) sideGeometry { t.Helper() mustRun(t, ctx, chromedp.EmulateViewport(width, 800), chromedp.Navigate(origin+path), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) settleUntil(t, ctx, `document.readyState === "complete"`) var g sideGeometry at(t, ctx, sideGeometryJS, &g) if len(g.Links) != 4 { t.Fatalf("%s: the nav holds %d links; the example has four", path, len(g.Links)) } return g } // sameEdge is whether a and b are the same edge, to half a pixel. func sameEdge(a, b float64) bool { return math.Abs(a-b) <= 0.5 } // TestTheSideNavIsAColumnBesideItsPanel: in a 640px panel the links // stack in a column with one shared edge, the content sits beside the // column, and a 1px hairline at the column's inline end divides them; // right to left, all of it mirrors. func TestTheSideNavIsAColumnBesideItsPanel(t *testing.T) { _, doc := sideNavExample(t) wide := inPanel(t, doc, "640px") rig := sizingRig(t, false, map[string]string{"/": wide, "/rtl": inArabic(t, wide)}) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() for _, path := range []string{"/", "/rtl"} { g := readSide(t, ctx, rig.Origin, path, 1280) rtl := path == "/rtl" // The link's edge at the column's inline start: the left in // English, the right in Arabic. start := func(b sideBox) float64 { if rtl { return b.Right } return b.Left } for i, l := range g.Links { if !sameEdge(start(l), start(g.Links[0])) { t.Errorf("%s: link %d starts at %.1f, link 1 at %.1f; the links of a column share one edge", path, i+1, start(l), start(g.Links[0])) } if i > 0 && l.Top < g.Links[i-1].Bottom-0.5 { t.Errorf("%s: link %d's top is %.1f, above link %d's bottom %.1f; the links stack", path, i+1, l.Top, i, g.Links[i-1].Bottom) } } if !rtl && g.Content.Left < g.Nav.Right-0.5 { t.Errorf("%s: the content's left edge is %.1f, left of the nav's right edge %.1f; the content sits beside the nav", path, g.Content.Left, g.Nav.Right) } if rtl && g.Content.Right > g.Nav.Left+0.5 { t.Errorf("%s: the content's right edge is %.1f, right of the nav's left edge %.1f; right to left, the content sits to the nav's left", path, g.Content.Right, g.Nav.Left) } if g.End != "1px" || g.Start != "0px" || g.Bottom != "0px" { t.Errorf("%s: the nav's borders are %s at its inline end, %s at its start and %s below; want one 1px hairline at the inline end", path, g.End, g.Start, g.Bottom) } if !sameEdge(g.Nav.Top, g.Content.Top) { t.Errorf("%s: the nav's top is %.1f and the content's %.1f; side by side, they start on one line", path, g.Nav.Top, g.Content.Top) } } } // TestANarrowPanelPutsTheLinksInARow: in a 300px panel on a wide // screen, and on a 320px phone, the nav spans the panel above the // content, its links one row, each label on one line, with the // hairline under the row; with // labels too long for the width, the row scrolls inside the nav while // the page itself never scrolls sideways. func TestANarrowPanelPutsTheLinksInARow(t *testing.T) { _, doc := sideNavExample(t) narrow, long := inPanel(t, doc, "300px"), inPanel(t, longLabels(t, doc), "300px") pages := map[string]string{ "/panel": narrow, "/panel-rtl": inArabic(t, narrow), "/panel-long": long, "/panel-long-rtl": inArabic(t, long), "/page": doc, "/page-rtl": inArabic(t, doc), "/page-long": longLabels(t, doc), "/page-long-rtl": inArabic(t, longLabels(t, doc)), } rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) defer cancel() for _, path := range slices.Sorted(maps.Keys(pages)) { width := int64(1280) if strings.HasPrefix(path, "/page") { width = 320 } where := fmt.Sprintf("%s at %d", path, width) g := readSide(t, ctx, rig.Origin, path, width) if !sameEdge(g.Nav.Left, g.Panel.Left) || !sameEdge(g.Nav.Right, g.Panel.Right) { t.Errorf("%s: the nav spans %.1f to %.1f in a panel of %.1f to %.1f; in a narrow panel it spans the panel", where, g.Nav.Left, g.Nav.Right, g.Panel.Left, g.Panel.Right) } if g.Content.Top < g.Nav.Bottom-0.5 { t.Errorf("%s: the content's top is %.1f, above the nav's bottom %.1f; in a narrow panel the nav sits above the content", where, g.Content.Top, g.Nav.Bottom) } for i, l := range g.Links { if !sameEdge(l.Top, g.Links[0].Top) { t.Errorf("%s: link %d's top is %.1f, link 1's %.1f; in a narrow panel the links are one row", where, i+1, l.Top, g.Links[0].Top) } if g.Lines[i] != 1 { t.Errorf("%s: link %d's label takes %d lines; in the row a label stays on one line and the row scrolls", where, i+1, g.Lines[i]) } } if g.Bottom != "1px" || g.End != "0px" || g.Start != "0px" { t.Errorf("%s: the nav's borders are %s below, %s at its inline end and %s at its start; want the hairline under the nav and none beside it", where, g.Bottom, g.End, g.Start) } if strings.Contains(path, "-long") && g.NavScroll <= g.NavClient { t.Errorf("%s: the nav's row is %.0fpx wide in %.0fpx; four long labels must overflow it, or this leg proves nothing about scrolling", where, g.NavScroll, g.NavClient) } if g.DocScroll > g.DocClient { t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; the row scrolls inside the nav, never the page", where, g.DocScroll, g.DocClient) } } } // arrivedPage is what each of the example's routes answers: a page // that a drive can tell it arrived at, and whose own script would // rewrite it, so the drive can also tell that scripts were off. const arrivedPage = `arrived

arrived

` + `` // TestTheSideNavLinksWorkWithNoScript: with scripts off, each link is // reached with Tab and followed with Enter to the route it names, and // every route is one the state declares, so the reference markup has no // link that goes nowhere and needs no script to switch sections. func TestTheSideNavLinksWorkWithNoScript(t *testing.T) { state, doc := sideNavExample(t) pages := map[string]string{"/": doc} for _, u := range state.SampleURLs { pages[u] = arrivedPage } rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true)) var hrefs []string mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getAttribute("href")))`, &hrefs) if len(hrefs) != 4 { t.Fatalf("the nav holds %d links; the example has four", len(hrefs)) } for _, h := range hrefs { if !slices.Contains(state.SampleURLs, h) { t.Fatalf("the link to %q is not one of the state's sampleURLs %v; a link must go somewhere the app has", h, state.SampleURLs) } } for i, h := range hrefs { mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) for range i + 1 { mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) } var focused string at(t, ctx, `JSON.stringify(document.activeElement.getAttribute("href") || document.activeElement.tagName)`, &focused) if focused != h { t.Errorf("after %d presses of Tab the focus is on %q, want the link to %q", i+1, focused, h) continue } var path, said string mustRun(t, ctx, chromedp.KeyEvent(kb.Enter), chromedp.WaitReady("#arrived", chromedp.ByQuery)) at(t, ctx, `JSON.stringify(location.pathname)`, &path) at(t, ctx, `JSON.stringify(document.getElementById("arrived").textContent)`, &said) if said != "arrived" { t.Fatalf("the route's own script ran (%q); this leg is not the page a reader with no script gets", said) } if path != h { t.Errorf("Enter on the link to %q arrived at %q", h, path) } } } // menuRowsPage is the example beside a dropdown's menu, open, with a // current item and a plain one, in Day's light scheme, so each link's // states are read against a menu row's under the same stylesheets. func menuRowsPage(t *testing.T, doc string) string { t.Helper() const menu = `
Menu
` + `OneTwo
` doc = strings.Replace(doc, ``, ``, 1) return strings.Replace(doc, "\n", "\n"+menu+"\n", 1) } // TestTheSideNavLinksAreMenuRows: in Day, light, with a mouse, the // current link wears the menu's current row, a hovered link the menu's // hovered row with its top highlight, a pressed one the press tint and // a tapped one the tap tint; every link is at least the target floor // tall; and a link focused from the keyboard shows the 2px ring though // no rst-page is around it. func TestTheSideNavLinksAreMenuRows(t *testing.T) { _, doc := sideNavExample(t) rig := sizingRig(t, false, map[string]string{"/": menuRowsPage(t, doc)}, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) var hover bool at(t, ctx, `JSON.stringify(matchMedia("(hover: hover)").matches)`, &hover) if !hover { t.Fatal("(hover: hover) is false on this rig; the hover rules cannot be read here") } still(t, ctx) const current, plain = `[rst-side-nav] a[aria-current]`, `[rst-side-nav] a[href="/settings/members"]` face := func(sel string, states ...string) (bg, fg, shadow string) { forceState(t, ctx, sel, states...) bg, fg, shadow = computed(t, ctx, sel, "background-color"), computed(t, ctx, sel, "color"), computed(t, ctx, sel, "box-shadow") forceState(t, ctx, sel) return bg, fg, shadow } for _, c := range []struct { what, link, item string states []string }{ {"current", current, "#menu-current", nil}, {"hovered", plain, "#menu-plain", []string{"hover"}}, {"pressed", plain, "#menu-plain", []string{"hover", "active"}}, {"tapped", plain, "#menu-plain", []string{"active"}}, } { lb, lf, ls := face(c.link, c.states...) mb, mf, ms := face(c.item, c.states...) if lb != mb || ls != ms { t.Errorf("the %s link is %s with box-shadow %q; the %s menu row is %s with %q", c.what, lb, ls, c.what, mb, ms) } if c.what != "tapped" && lf != mf { t.Errorf("the %s link's text is %s; the %s menu row's is %s", c.what, lf, c.what, mf) } } if rest, _, _ := face(plain); rest == computed(t, ctx, "#menu-current", "background-color") { t.Errorf("a link at rest is %s, the current row's wash; only the current link is on it", rest) } if w := computed(t, ctx, current, "font-weight"); w != "600" { t.Errorf("the current link's weight is %s, want 600, as a current menu row's label", w) } var floor struct { Target float64 Heights []float64 } at(t, ctx, `(() => { const p = document.createElement("div"); p.style.blockSize = "var(--rst-target)"; document.body.append(p); const target = p.getBoundingClientRect().height; p.remove(); return JSON.stringify({Target: target, Heights: [...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getBoundingClientRect().height)}); })()`, &floor) if floor.Target < 30 { t.Fatalf("--rst-target reads %.1fpx; the probe is broken, not the links", floor.Target) } for i, h := range floor.Heights { if h < floor.Target-0.5 { t.Errorf("link %d is %.1fpx tall, under the %.1fpx target floor", i+1, h, floor.Target) } } mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) var ring struct { Href, Style, Width string InPage bool } at(t, ctx, `(() => { const a = document.activeElement, s = getComputedStyle(a); return JSON.stringify({Href: a.getAttribute("href") || a.tagName, Style: s.outlineStyle, Width: s.outlineWidth, InPage: !!a.closest("[rst-page], .rst-page")}); })()`, &ring) if ring.Href != "/settings/general" { t.Fatalf("the first Tab focused %q, not the first link", ring.Href) } if ring.InPage { t.Fatal("the focused link is inside an rst-page; this leg is for a side nav outside one") } if ring.Style != "solid" || ring.Width != "2px" { t.Errorf("a link focused from the keyboard has outline %s %s, want solid 2px though no rst-page is around it", ring.Style, ring.Width) } } // ringJS reads the focused element's whole focus ring (its border box // grown by the outline's offset and width, which a negative offset // shrinks) against the nav's scrollport (its padding box), where the // row's overflow clips anything outside. const ringJS = `(() => { const a = document.activeElement, nav = a.closest("[rst-side-nav]"); if (!nav) return JSON.stringify({Href: a.getAttribute("href") || a.tagName}); const s = getComputedStyle(a), ns = getComputedStyle(nav), r = a.getBoundingClientRect(), n = nav.getBoundingClientRect(); const px = (v) => parseFloat(v) || 0, grow = s.outlineStyle === "none" ? 0 : px(s.outlineOffset) + px(s.outlineWidth); return JSON.stringify({Href: a.getAttribute("href"), Style: s.outlineStyle, Grow: grow, Ring: {Left: r.left - grow, Right: r.right + grow, Top: r.top - grow, Bottom: r.bottom + grow}, Port: {Left: n.left + px(ns.borderLeftWidth), Right: n.right - px(ns.borderRightWidth), Top: n.top + px(ns.borderTopWidth), Bottom: n.bottom - px(ns.borderBottomWidth)}}); })()` // TestKeyboardFocusShowsTheWholeRingInTheRow: in a narrow panel whose // row of links is wider than the panel, Tab reaches each link in turn, // links that start out of view included, and Shift+Tab walks back to // the first after the row has scrolled to its end; each focused link // and its whole focus ring are inside the row's visible part, left to // right and right to left; and the ring is drawn inside the link, since // the row's edge cuts anything outside a link that meets it. func TestKeyboardFocusShowsTheWholeRingInTheRow(t *testing.T) { _, doc := sideNavExample(t) long := inPanel(t, longLabels(t, doc), "300px") pages := map[string]string{"/": long, "/rtl": inArabic(t, long), "/page": longLabels(t, doc), "/page-rtl": inArabic(t, longLabels(t, doc))} rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() for _, path := range slices.Sorted(maps.Keys(pages)) { width := int64(1280) if strings.HasPrefix(path, "/page") { width = 320 } g := readSide(t, ctx, rig.Origin, path, width) if g.NavScroll <= g.NavClient { t.Fatalf("%s: the row is %.0fpx in %.0fpx; with nothing out of view this leg proves nothing", path, g.NavScroll, g.NavClient) } var hrefs []string at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getAttribute("href")))`, &hrefs) var hidden int at(t, ctx, `(() => { const n = document.querySelector("[rst-side-nav]").getBoundingClientRect(); return JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].filter(a => { const r = a.getBoundingClientRect(); return r.right > n.right + 0.5 || r.left < n.left - 0.5; }).length); })()`, &hidden) if hidden == 0 { t.Fatalf("%s: every link starts in view; this leg is for links the row has to scroll to", path) } steps := []struct { key string want string }{} for _, h := range hrefs { steps = append(steps, struct{ key, want string }{"Tab", h}) } for i := len(hrefs) - 2; i >= 0; i-- { steps = append(steps, struct{ key, want string }{"Shift+Tab", hrefs[i]}) } for _, s := range steps { if s.key == "Tab" { mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) } else { mustRun(t, ctx, chromedp.KeyEvent(kb.Tab, chromedp.KeyModifiers(input.ModifierShift))) } var f struct { Href, Style string Grow float64 Ring, Port sideBox } at(t, ctx, ringJS, &f) where := fmt.Sprintf("%s at %d, %s to %s", path, width, s.key, s.want) if f.Href != s.want { t.Errorf("%s: the focus is on %q", where, f.Href) continue } if f.Style == "none" { t.Errorf("%s: the focused link draws no outline", where) } if f.Grow > 0 { t.Errorf("%s: the focus ring reaches %.1fpx outside the link; in the row it is drawn inside, or the row's edge cuts it wherever the link meets that edge", where, f.Grow) } if f.Ring.Left < f.Port.Left-0.5 || f.Ring.Right > f.Port.Right+0.5 || f.Ring.Top < f.Port.Top-0.5 || f.Ring.Bottom > f.Port.Bottom+0.5 { t.Errorf("%s: the focus ring spans %.1f to %.1f across and %.1f to %.1f down, outside the row's visible %.1f to %.1f and %.1f to %.1f; the row cuts it off", where, f.Ring.Left, f.Ring.Right, f.Ring.Top, f.Ring.Bottom, f.Port.Left, f.Port.Right, f.Port.Top, f.Port.Bottom) } } } } // flexRows are a side in a bounded flex row: given its width by // flex: 1 1 0 beside a 200px column, in a wide row and a narrow one; // and left to size itself, as a flex item does by its content. const flexRows = `
Aside
%[1]s
` + `
Aside
%[1]s
` + `
%[2]s
` // TestASideInAFlexRowIsGivenItsWidth: the side answers to its own // width, so it cannot take that width from its content; a flex item // sized by its content is zero wide. Given flex: 1 1 0 in a bounded // row it fills what the row leaves, a column of links in a wide row // and a row of links in a narrow one. Left to size itself it // collapses, which is why a page must give it its width; if a browser // ever sizes it some other way, this says so. func TestASideInAFlexRowIsGivenItsWidth(t *testing.T) { _, doc := sideNavExample(t) side, err := example.Element(doc, "rst-side") if err != nil { t.Fatal(err) } given := strings.Replace(side, "
", `
`, 1) if given == side { t.Fatal("the example's side is not written
") } page := strings.Replace(doc, side, fmt.Sprintf(flexRows, given, side), 1) rig := sizingRig(t, false, map[string]string{"/": page}) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#unsized", chromedp.ByQuery)) var got map[string]struct { Width float64 Tops int Flow string Content float64 } at(t, ctx, `(() => { const out = {}; for (const id of ["wide", "narrow", "unsized"]) { const side = document.querySelector("#" + id + " [rst-side]"), nav = side.querySelector("[rst-side-nav]"); out[id] = {Width: side.getBoundingClientRect().width, Tops: new Set([...nav.querySelectorAll("a")].map(a => Math.round(a.getBoundingClientRect().top))).size, Flow: getComputedStyle(nav).gridAutoFlow, Content: side.querySelector(":scope > :not([rst-side-nav])").getBoundingClientRect().width}; } return JSON.stringify(out); })()`, &got) if w := got["wide"]; !sameEdge(w.Width, 800) || w.Tops != 4 || w.Content <= 0 { t.Errorf("in a 1000px row beside a 200px column, flex: 1 1 0: the side is %.1fpx wide, its links on %d lines, its content %.1fpx; want 800px, a column of four, and room for the content", w.Width, w.Tops, w.Content) } if n := got["narrow"]; !sameEdge(n.Width, 300) || n.Tops != 1 || n.Flow != "column" { t.Errorf("in a 500px row beside a 200px column, flex: 1 1 0: the side is %.1fpx wide, its links on %d lines, flowing %s; want 300px and one row", n.Width, n.Tops, n.Flow) } if u := got["unsized"]; u.Width > 1 { t.Errorf("left to size itself in a flex row, the side is %.1fpx wide; it was zero wide, and pages are told to give it its width: change that advice if this is now true", u.Width) } }