| 1 | //go:build browser |
| 2 | |
| 3 | // The snippet example as a reader meets it: a line of code in a box no |
| 4 | // wider than the line on a desktop and no wider than the screen on a |
| 5 | // phone, where the line wraps rather than run off the edge, still left |
| 6 | // to right in a right-to-left page; with no script the Copy button is |
| 7 | // gone, the code is selected whole by one click, and the status is a |
| 8 | // live region before it ever speaks. |
| 9 | package check |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "fmt" |
| 14 | "math" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/chromedp/cdproto/accessibility" |
| 19 | "github.com/chromedp/cdproto/cdp" |
| 20 | "github.com/chromedp/cdproto/emulation" |
| 21 | "github.com/chromedp/chromedp" |
| 22 | |
| 23 | "amadan.net/rastrillo/fiesta" |
| 24 | "amadan.net/rastrillo/fiesta/example" |
| 25 | ) |
| 26 | |
| 27 | // snippetExampleDoc is the snippet example's one state as a document, |
| 28 | // its assets flattened beside the page as the sizing mux serves them. |
| 29 | func snippetExampleDoc(t *testing.T) string { |
| 30 | t.Helper() |
| 31 | state, ok := manifest(t).Default("snippet") |
| 32 | if !ok || state == "" { |
| 33 | t.Fatal("examples.json names no default state for snippet") |
| 34 | } |
| 35 | doc, err := example.Document(fiesta.FS(), "snippet", state) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | return parityLinks(doc) |
| 40 | } |
| 41 | |
| 42 | // snippetPages are the example at / and right to left at /rtl. |
| 43 | func snippetPages(t *testing.T) map[string]string { |
| 44 | t.Helper() |
| 45 | doc := snippetExampleDoc(t) |
| 46 | return map[string]string{"/": doc, "/rtl": inArabic(t, doc)} |
| 47 | } |
| 48 | |
| 49 | // snippetGeometry is what the layout test reads of the box: its width |
| 50 | // and its container's content width, the code's one-line width (a |
| 51 | // clone of it set on one line), its height and line height, the |
| 52 | // button's width with its inline margins when shown, the box's column |
| 53 | // gap, its paddings and borders, the rect of the code's last character, the document's |
| 54 | // scroll and client widths, and the code's direction. |
| 55 | type snippetGeometry struct { |
| 56 | Box, BoxLeft, BoxRight, Container float64 |
| 57 | Line, CodeHeight, LineHeight, Button, Gap float64 |
| 58 | Chrome float64 |
| 59 | ButtonShown bool |
| 60 | LastLeft, LastRight, ScrollWidth, ClientWidth float64 |
| 61 | Direction string |
| 62 | } |
| 63 | |
| 64 | const snippetGeometryJS = `(() => { |
| 65 | const box = document.querySelector("[rst-snippet]"), code = box.querySelector("code"), |
| 66 | b = box.querySelector("[rst-snippet-copy]"), bs = getComputedStyle(box), cs = getComputedStyle(code); |
| 67 | const parent = box.parentElement, ps = getComputedStyle(parent); |
| 68 | const clone = code.cloneNode(true); |
| 69 | clone.style.cssText = "position: absolute; visibility: hidden; white-space: nowrap; inline-size: max-content"; |
| 70 | box.append(clone); |
| 71 | const line = clone.getBoundingClientRect().width; |
| 72 | clone.remove(); |
| 73 | const text = code.firstChild, last = document.createRange(); |
| 74 | last.setStart(text, text.length - 1); |
| 75 | last.setEnd(text, text.length); |
| 76 | const lr = last.getBoundingClientRect(), r = box.getBoundingClientRect(); |
| 77 | const px = (v) => parseFloat(v) || 0; |
| 78 | const lh = cs.lineHeight === "normal" ? 1.2 * px(cs.fontSize) : px(cs.lineHeight); |
| 79 | return JSON.stringify({ |
| 80 | Box: r.width, BoxLeft: r.left, BoxRight: r.right, |
| 81 | Container: parent.clientWidth - px(ps.paddingLeft) - px(ps.paddingRight), |
| 82 | Line: line, CodeHeight: code.getBoundingClientRect().height, LineHeight: lh, |
| 83 | ButtonShown: b.getClientRects().length > 0, |
| 84 | Button: b.getBoundingClientRect().width + px(getComputedStyle(b).marginLeft) + px(getComputedStyle(b).marginRight), |
| 85 | Gap: px(bs.columnGap), |
| 86 | Chrome: px(bs.paddingLeft) + px(bs.paddingRight) + px(bs.borderLeftWidth) + px(bs.borderRightWidth), |
| 87 | LastLeft: lr.left, LastRight: lr.right, |
| 88 | ScrollWidth: document.documentElement.scrollWidth, ClientWidth: document.documentElement.clientWidth, |
| 89 | Direction: cs.direction}); |
| 90 | })()` |
| 91 | |
| 92 | // TestTheSnippetHugsItsLineAndWrapsOnAPhone: at 1280 the box is as |
| 93 | // wide as the line of code, the Copy button and the box's own gap, |
| 94 | // padding and border, with the code on one line, so a short line does |
| 95 | // not sit in a box the width of the page. At 390 and 320 the box is no |
| 96 | // wider than its container and the code wraps inside it, its last |
| 97 | // character still in the box and the page never wider than the |
| 98 | // screen: the line a reader is told to copy is all on screen. Right to |
| 99 | // left, the code still reads left to right. |
| 100 | func TestTheSnippetHugsItsLineAndWrapsOnAPhone(t *testing.T) { |
| 101 | rig := sizingRig(t, false, snippetPages(t)) |
| 102 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 103 | defer cancel() |
| 104 | grantClipboard(t, ctx, rig.Origin) |
| 105 | for _, w := range []int64{1280, 390, 320} { |
| 106 | for _, path := range []string{"/", "/rtl"} { |
| 107 | where := fmt.Sprintf("%s at %d", path, w) |
| 108 | mustRun(t, ctx, chromedp.EmulateViewport(w, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body", chromedp.ByQuery)) |
| 109 | settleUntil(t, ctx, `document.readyState === "complete"`) |
| 110 | var g snippetGeometry |
| 111 | at(t, ctx, snippetGeometryJS, &g) |
| 112 | if !g.ButtonShown { |
| 113 | t.Fatalf("%s: the Copy button is not shown with a clipboard; the widths below would leave it out", where) |
| 114 | } |
| 115 | if g.Direction != "ltr" { |
| 116 | t.Errorf("%s: the code's direction is %s; code reads left to right in any page", where, g.Direction) |
| 117 | } |
| 118 | if w == 1280 { |
| 119 | want := g.Line + g.Gap + g.Button + g.Chrome |
| 120 | if math.Abs(g.Box-want) > 2 { |
| 121 | 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) |
| 122 | } |
| 123 | if g.CodeHeight > 1.5*g.LineHeight { |
| 124 | t.Errorf("%s: the code is %.1fpx tall, its line %.1fpx; at this width it fits on one line", where, g.CodeHeight, g.LineHeight) |
| 125 | } |
| 126 | continue |
| 127 | } |
| 128 | if g.Box > g.Container+0.5 { |
| 129 | t.Errorf("%s: the box is %.1fpx wide in a container of %.1fpx", where, g.Box, g.Container) |
| 130 | } |
| 131 | if g.CodeHeight < 1.5*g.LineHeight { |
| 132 | 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) |
| 133 | } |
| 134 | if g.LastLeft < g.BoxLeft-0.5 || g.LastRight > g.BoxRight+0.5 { |
| 135 | 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) |
| 136 | } |
| 137 | if g.ScrollWidth > g.ClientWidth { |
| 138 | t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; the code must wrap, not scroll sideways", where, g.ScrollWidth, g.ClientWidth) |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // statusInTheTree says whether the element sel finds is a status node |
| 145 | // in the accessibility tree: a live region that is not in the tree |
| 146 | // when its first message arrives may never be announced. |
| 147 | func statusInTheTree(t *testing.T, ctx context.Context, sel string) bool { |
| 148 | t.Helper() |
| 149 | var nodes []*cdp.Node |
| 150 | mustRun(t, ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)) |
| 151 | // Read loosely: cdproto's Node refuses an ignored reason it has no |
| 152 | // constant for (Chromium's "uninteresting"), and this needs only |
| 153 | // the role, the ignored flag and the DOM node. |
| 154 | var tree struct { |
| 155 | Nodes []struct { |
| 156 | Ignored bool `json:"ignored"` |
| 157 | Role *struct { |
| 158 | Value any `json:"value"` |
| 159 | } `json:"role"` |
| 160 | BackendDOMNodeID cdp.BackendNodeID `json:"backendDOMNodeId"` |
| 161 | } `json:"nodes"` |
| 162 | } |
| 163 | mustRun(t, ctx, accessibility.Enable(), chromedp.ActionFunc(func(ctx context.Context) error { |
| 164 | return cdp.Execute(ctx, accessibility.CommandGetFullAXTree, accessibility.GetFullAXTree(), &tree) |
| 165 | })) |
| 166 | if len(tree.Nodes) == 0 { |
| 167 | t.Fatal("the accessibility tree came back empty; the read is broken, not the status") |
| 168 | } |
| 169 | for _, n := range tree.Nodes { |
| 170 | if n.BackendDOMNodeID == nodes[0].BackendNodeID { |
| 171 | return !n.Ignored && n.Role != nil && n.Role.Value == "status" |
| 172 | } |
| 173 | } |
| 174 | return false |
| 175 | } |
| 176 | |
| 177 | // TestTheSnippetWithNoScript: with scripts off there is no Copy |
| 178 | // button to press and do nothing; the code is selected whole by one |
| 179 | // click or tap (user-select: all), so it can still be copied by hand; |
| 180 | // and the empty status takes no room, though it is a status node in |
| 181 | // the accessibility tree, clipped rather than display: none. |
| 182 | func TestTheSnippetWithNoScript(t *testing.T) { |
| 183 | rig := sizingRig(t, false, snippetPages(t)) |
| 184 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 185 | defer cancel() |
| 186 | mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true)) |
| 187 | for _, path := range []string{"/", "/rtl"} { |
| 188 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body", chromedp.ByQuery)) |
| 189 | var r struct { |
| 190 | Ran, Shown bool |
| 191 | Width, Height float64 |
| 192 | UserSelect, WebkitSelect string |
| 193 | } |
| 194 | at(t, ctx, `(() => { |
| 195 | const box = document.querySelector("[rst-snippet]"), s = box.querySelector("[rst-snippet-status]").getBoundingClientRect(), |
| 196 | cs = getComputedStyle(box.querySelector("code")); |
| 197 | return JSON.stringify({Ran: window.fiestaCopy === true, Shown: box.querySelector("[rst-snippet-copy]").getClientRects().length > 0, |
| 198 | Width: s.width, Height: s.height, UserSelect: cs.userSelect, WebkitSelect: cs.webkitUserSelect}); })()`, &r) |
| 199 | if r.Ran { |
| 200 | t.Fatalf("%s: copy.js ran with scripts off; this leg is not the page a reader with no script gets", path) |
| 201 | } |
| 202 | if r.Shown { |
| 203 | t.Errorf("%s: with no script the Copy button is displayed; it could only do nothing", path) |
| 204 | } |
| 205 | if r.Width > 1 || r.Height > 1 { |
| 206 | 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) |
| 207 | } |
| 208 | if r.UserSelect != "all" && r.WebkitSelect != "all" { |
| 209 | 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) |
| 210 | } |
| 211 | if !statusInTheTree(t, ctx, "[rst-snippet-status]") { |
| 212 | t.Errorf("%s: the empty status is not a status node in the accessibility tree", path) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // TestTheSnippetExampleCopies: the gallery's own snippet document takes |
| 218 | // driveCopy's journey, so what the gallery shows is markup copy.js |
| 219 | // works on, with the example's own strings. |
| 220 | func TestTheSnippetExampleCopies(t *testing.T) { |
| 221 | rig := sizingRig(t, false, snippetPages(t)) |
| 222 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 223 | defer cancel() |
| 224 | driveCopy(t, ctx, rig.Origin, "/") |
| 225 | rig.Screen("body", "the snippet example after two copies") |
| 226 | } |
| 227 | |