//go:build browser // The snippet example as a reader meets it: a line of code in a box no // wider than the line on a desktop and no wider than the screen on a // phone, where the line wraps rather than run off the edge, still left // to right in a right-to-left page; with no script the Copy button is // gone, the code is selected whole by one click, and the status is a // live region before it ever speaks. package check import ( "context" "fmt" "math" "testing" "time" "github.com/chromedp/cdproto/accessibility" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" ) // snippetExampleDoc is the snippet example's one state as a document, // its assets flattened beside the page as the sizing mux serves them. func snippetExampleDoc(t *testing.T) string { t.Helper() state, ok := manifest(t).Default("snippet") if !ok || state == "" { t.Fatal("examples.json names no default state for snippet") } doc, err := example.Document(fiesta.FS(), "snippet", state) if err != nil { t.Fatal(err) } return parityLinks(doc) } // snippetPages are the example at / and right to left at /rtl. func snippetPages(t *testing.T) map[string]string { t.Helper() doc := snippetExampleDoc(t) return map[string]string{"/": doc, "/rtl": inArabic(t, doc)} } // snippetGeometry is what the layout test reads of the box: its width // and its container's content width, the code's one-line width (a // clone of it set on one line), its height and line height, the // button's width with its inline margins when shown, the box's column // gap, its paddings and borders, the rect of the code's last character, the document's // scroll and client widths, and the code's direction. type snippetGeometry struct { Box, BoxLeft, BoxRight, Container float64 Line, CodeHeight, LineHeight, Button, Gap float64 Chrome float64 ButtonShown bool LastLeft, LastRight, ScrollWidth, ClientWidth float64 Direction string } const snippetGeometryJS = `(() => { const box = document.querySelector("[rst-snippet]"), code = box.querySelector("code"), b = box.querySelector("[rst-snippet-copy]"), bs = getComputedStyle(box), cs = getComputedStyle(code); const parent = box.parentElement, ps = getComputedStyle(parent); const clone = code.cloneNode(true); clone.style.cssText = "position: absolute; visibility: hidden; white-space: nowrap; inline-size: max-content"; box.append(clone); const line = clone.getBoundingClientRect().width; clone.remove(); const text = code.firstChild, last = document.createRange(); last.setStart(text, text.length - 1); last.setEnd(text, text.length); const lr = last.getBoundingClientRect(), r = box.getBoundingClientRect(); const px = (v) => parseFloat(v) || 0; const lh = cs.lineHeight === "normal" ? 1.2 * px(cs.fontSize) : px(cs.lineHeight); return JSON.stringify({ Box: r.width, BoxLeft: r.left, BoxRight: r.right, Container: parent.clientWidth - px(ps.paddingLeft) - px(ps.paddingRight), Line: line, CodeHeight: code.getBoundingClientRect().height, LineHeight: lh, ButtonShown: b.getClientRects().length > 0, Button: b.getBoundingClientRect().width + px(getComputedStyle(b).marginLeft) + px(getComputedStyle(b).marginRight), Gap: px(bs.columnGap), Chrome: px(bs.paddingLeft) + px(bs.paddingRight) + px(bs.borderLeftWidth) + px(bs.borderRightWidth), LastLeft: lr.left, LastRight: lr.right, ScrollWidth: document.documentElement.scrollWidth, ClientWidth: document.documentElement.clientWidth, Direction: cs.direction}); })()` // TestTheSnippetHugsItsLineAndWrapsOnAPhone: at 1280 the box is as // wide as the line of code, the Copy button and the box's own gap, // padding and border, with the code on one line, so a short line does // not sit in a box the width of the page. At 390 and 320 the box is no // wider than its container and the code wraps inside it, its last // character still in the box and the page never wider than the // screen: the line a reader is told to copy is all on screen. Right to // left, the code still reads left to right. func TestTheSnippetHugsItsLineAndWrapsOnAPhone(t *testing.T) { rig := sizingRig(t, false, snippetPages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() grantClipboard(t, ctx, rig.Origin) for _, w := range []int64{1280, 390, 320} { for _, path := range []string{"/", "/rtl"} { where := fmt.Sprintf("%s at %d", path, w) mustRun(t, ctx, chromedp.EmulateViewport(w, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body", chromedp.ByQuery)) settleUntil(t, ctx, `document.readyState === "complete"`) var g snippetGeometry at(t, ctx, snippetGeometryJS, &g) if !g.ButtonShown { t.Fatalf("%s: the Copy button is not shown with a clipboard; the widths below would leave it out", where) } if g.Direction != "ltr" { t.Errorf("%s: the code's direction is %s; code reads left to right in any page", where, g.Direction) } if w == 1280 { want := g.Line + g.Gap + g.Button + g.Chrome if math.Abs(g.Box-want) > 2 { t.Errorf("%s: the box is %.1fpx wide; want %.1fpx, the line (%.1f), the gap (%.1f), the button (%.1f) and the padding and border (%.1f)", where, g.Box, want, g.Line, g.Gap, g.Button, g.Chrome) } if g.CodeHeight > 1.5*g.LineHeight { t.Errorf("%s: the code is %.1fpx tall, its line %.1fpx; at this width it fits on one line", where, g.CodeHeight, g.LineHeight) } continue } if g.Box > g.Container+0.5 { t.Errorf("%s: the box is %.1fpx wide in a container of %.1fpx", where, g.Box, g.Container) } if g.CodeHeight < 1.5*g.LineHeight { t.Errorf("%s: the code is %.1fpx tall, its line %.1fpx; a line this long must wrap on a phone", where, g.CodeHeight, g.LineHeight) } if g.LastLeft < g.BoxLeft-0.5 || g.LastRight > g.BoxRight+0.5 { t.Errorf("%s: the code's last character spans %.1f to %.1f, outside the box's %.1f to %.1f", where, g.LastLeft, g.LastRight, g.BoxLeft, g.BoxRight) } if g.ScrollWidth > g.ClientWidth { t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; the code must wrap, not scroll sideways", where, g.ScrollWidth, g.ClientWidth) } } } } // statusInTheTree says whether the element sel finds is a status node // in the accessibility tree: a live region that is not in the tree // when its first message arrives may never be announced. func statusInTheTree(t *testing.T, ctx context.Context, sel string) bool { t.Helper() var nodes []*cdp.Node mustRun(t, ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)) // Read loosely: cdproto's Node refuses an ignored reason it has no // constant for (Chromium's "uninteresting"), and this needs only // the role, the ignored flag and the DOM node. var tree struct { Nodes []struct { Ignored bool `json:"ignored"` Role *struct { Value any `json:"value"` } `json:"role"` BackendDOMNodeID cdp.BackendNodeID `json:"backendDOMNodeId"` } `json:"nodes"` } mustRun(t, ctx, accessibility.Enable(), chromedp.ActionFunc(func(ctx context.Context) error { return cdp.Execute(ctx, accessibility.CommandGetFullAXTree, accessibility.GetFullAXTree(), &tree) })) if len(tree.Nodes) == 0 { t.Fatal("the accessibility tree came back empty; the read is broken, not the status") } for _, n := range tree.Nodes { if n.BackendDOMNodeID == nodes[0].BackendNodeID { return !n.Ignored && n.Role != nil && n.Role.Value == "status" } } return false } // TestTheSnippetWithNoScript: with scripts off there is no Copy // button to press and do nothing; the code is selected whole by one // click or tap (user-select: all), so it can still be copied by hand; // and the empty status takes no room, though it is a status node in // the accessibility tree, clipped rather than display: none. func TestTheSnippetWithNoScript(t *testing.T) { rig := sizingRig(t, false, snippetPages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true)) for _, path := range []string{"/", "/rtl"} { mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body", chromedp.ByQuery)) var r struct { Ran, Shown bool Width, Height float64 UserSelect, WebkitSelect string } at(t, ctx, `(() => { const box = document.querySelector("[rst-snippet]"), s = box.querySelector("[rst-snippet-status]").getBoundingClientRect(), cs = getComputedStyle(box.querySelector("code")); return JSON.stringify({Ran: window.fiestaCopy === true, Shown: box.querySelector("[rst-snippet-copy]").getClientRects().length > 0, Width: s.width, Height: s.height, UserSelect: cs.userSelect, WebkitSelect: cs.webkitUserSelect}); })()`, &r) if r.Ran { t.Fatalf("%s: copy.js ran with scripts off; this leg is not the page a reader with no script gets", path) } if r.Shown { t.Errorf("%s: with no script the Copy button is displayed; it could only do nothing", path) } if r.Width > 1 || r.Height > 1 { t.Errorf("%s: the empty status is %.1f by %.1f; while it has nothing to say it takes no room", path, r.Width, r.Height) } if r.UserSelect != "all" && r.WebkitSelect != "all" { t.Errorf("%s: the code's user-select is %q (-webkit- %q); want all, so one click selects the whole line to copy by hand", path, r.UserSelect, r.WebkitSelect) } if !statusInTheTree(t, ctx, "[rst-snippet-status]") { t.Errorf("%s: the empty status is not a status node in the accessibility tree", path) } } } // TestTheSnippetExampleCopies: the gallery's own snippet document takes // driveCopy's journey, so what the gallery shows is markup copy.js // works on, with the example's own strings. func TestTheSnippetExampleCopies(t *testing.T) { rig := sizingRig(t, false, snippetPages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() driveCopy(t, ctx, rig.Origin, "/") rig.Screen("body", "the snippet example after two copies") }