rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3// Browser drives for the things a Go test cannot see, because they are
4// geometry a real engine computes: whether a menu opened inside a card
5// is actually painted outside it, whether a child that owns its own
6// corners keeps them inside one, and where the sidebar rail puts the
7// person and which way its language menu opens.
8//
9// Run with the rest of the family:
10//
11// go test -tags browser ./check/
12//
13// There is no script under test in this file at all: every page here
14// is static markup plus tokens.css and a theme, and the only
15// interaction is a click on a <details> summary, which the browser
16// handles synchronously. The pages are the ones rastrillo's drives
17// rendered at the seed commit, read through fixture
18// (testdata/fixtures/), and the two routes every page links come
19// from stylesheets (rig_browser_test.go).
20package check
21
22import (
23 "context"
24 "encoding/json"
25 "fmt"
26 "net/http"
27 "strings"
28 "testing"
29 "time"
30
31 "github.com/chromedp/cdproto/runtime"
32 "github.com/chromedp/chromedp"
33
34 "amadan.net/rastrillo/fiesta"
35 "amadan.net/rastrillo/fiesta/internal/harness"
36)
37
38// shellPiece is one seeded piece of this file's pages: the partial a
39// drive rendered at the seed commit, by the drive's name and the piece's.
40func shellPiece(t *testing.T, test, piece string) string {
41 t.Helper()
42 return fixture(t, "shell_browser_test.go", test, piece)
43}
44
45// clipReading is what the clipping drive measures. Overhang and
46// HitIsInMenu are the two halves of one claim: the menu really does
47// hang below the card (or the hit test proves nothing), and the part
48// that hangs below is really painted (or the card is still clipping).
49type clipReading struct {
50 Overhang int
51 HitIsInMenu bool
52 Hit string
53 FirstCorner string
54 LastCorner string
55 CardOverflow string
56}
57
58// TestAMenuOpenedInsideAListCardEscapesTheCard is the live-page bug
59// Paul found: a bulk bar's Actions menu opened inside a [rst-list] was
60// sliced off at the card's edge.
61//
62// The cause was [rst-list]'s overflow: hidden, which was there to clip
63// the rows' corners to the card's radius and, in doing so, made the
64// card a clipping context for every absolutely positioned panel inside
65// it.
66//
67// getBoundingClientRect is NOT the measurement: an ancestor's overflow
68// does not change a descendant's layout rect, so the menu's rect hangs
69// below the card whether or not it is visible. What clipping does
70// change is hit testing, so the assertion is elementFromPoint at a
71// spot below the card's own bottom edge: clipped, nothing of the menu
72// is there; unclipped, the menu is.
73//
74// The second half is the fix's own risk. Dropping the overflow must
75// not leave the first and last rows square inside a rounded card, so
76// the corner radii are read back off the engine too.
77func TestAMenuOpenedInsideAListCardEscapesTheCard(t *testing.T) {
78 // The bulk bar as rastrillo rendered it: three selected, an Actions menu
79 // of Export, Unpublish and a dangerous Delete.
80 bulkBar := shellPiece(t, "TestAMenuOpenedInsideAListCardEscapesTheCard", "bulk-bar")
81
82 mux := http.NewServeMux()
83 stylesheets(t, mux)
84 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
85 var body strings.Builder
86 body.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
87 `<title>list card</title><link rel="stylesheet" href="/tokens.css">` +
88 `<link rel="stylesheet" href="/theme.css"></head><body>` +
89 `<div rst-page><form method="post" action="/act">` +
90 `<div rst-list>`)
91 body.WriteString(bulkBar)
92 body.WriteString(`</div></form></div></body></html>`)
93 w.Header().Set("Content-Type", "text/html; charset=utf-8")
94 fmt.Fprint(w, body.String())
95 })
96
97 rig := harness.New(t, func(string) http.Handler { return mux })
98 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
99 defer cancel()
100
101 var raw string
102 if err := chromedp.Run(ctx,
103 chromedp.EmulateViewport(1280, 900),
104 chromedp.Navigate(rig.Origin+"/"),
105 chromedp.WaitVisible(`[rst-list] [rst-bulkbar]`, chromedp.ByQuery),
106 // The menu is a native <details>: one real click on the
107 // summary, no script anywhere.
108 chromedp.Click(`[rst-list] [rst-bulkbar] details > summary`, chromedp.ByQuery),
109 chromedp.WaitVisible(`[rst-list] [rst-dropdown-menu]`, chromedp.ByQuery),
110 chromedp.Evaluate(`(() => {
111 const card = document.querySelector("[rst-list]");
112 const menu = document.querySelector("[rst-list] [rst-dropdown-menu]");
113 const c = card.getBoundingClientRect();
114 const m = menu.getBoundingClientRect();
115 // A point inside the menu and below the card's own bottom
116 // edge — the part a clipping card throws away.
117 const x = Math.round(m.left + m.width / 2);
118 const y = Math.round(c.bottom + Math.max(2, (m.bottom - c.bottom) / 2));
119 const hit = document.elementFromPoint(x, y);
120 const first = card.firstElementChild, last = card.lastElementChild;
121 return JSON.stringify({
122 Overhang: Math.round(m.bottom - c.bottom),
123 HitIsInMenu: !!(hit && menu.contains(hit)),
124 Hit: hit ? (hit.tagName.toLowerCase() + "." + (hit.className || "")) : "nothing",
125 FirstCorner: getComputedStyle(first).borderStartStartRadius,
126 LastCorner: getComputedStyle(last).borderEndEndRadius,
127 CardOverflow: getComputedStyle(card).overflowY
128 });
129 })()`, &raw),
130 ); err != nil {
131 t.Fatalf("driving the list card: %v", err)
132 }
133
134 var got clipReading
135 if err := json.Unmarshal([]byte(raw), &got); err != nil {
136 t.Fatalf("reading the measurement (%q): %v", raw, err)
137 }
138
139 // Sanity first: without an overhang the hit test below proves
140 // nothing at all.
141 if got.Overhang <= 4 {
142 t.Fatalf("the open menu hangs only %dpx below the card; this drive cannot tell clipped from contained", got.Overhang)
143 }
144 if !got.HitIsInMenu {
145 t.Errorf("a point %dpx of overhang inside the open menu hit %s, not the menu: the card is still clipping it (overflow-y: %s)",
146 got.Overhang, got.Hit, got.CardOverflow)
147 }
148 // And the card still draws itself as a card: the rows it holds are
149 // rounded to its corners rather than squared off at them.
150 for _, corner := range []struct{ name, value string }{
151 {"the first row's start corner", got.FirstCorner},
152 {"the last row's end corner", got.LastCorner},
153 } {
154 if corner.value == "" || corner.value == "0px" {
155 t.Errorf("%s is %q — dropping the card's overflow left its rows square inside a rounded border", corner.name, corner.value)
156 }
157 }
158}
159
160// railReading is where the sidebar shell's rail puts things, in the
161// window Paul was looking at.
162type railReading struct {
163 RailHeight int
164 FootGap int
165 MenuLift int
166 // The frame the first version of this drive did not have. FootGap
167 // answers "is the person at the foot of the rail", which was true
168 // on the day the rail overflowed the window by its own padding —
169 // the rail was 932px in a 900px viewport, the number was in this
170 // test's own passing output, and the person was at the foot of a box
171 // whose last 32px were under the fold. Viewport and the two
172 // overhangs are what turn a claim about the rail into a claim about
173 // the window it has to fit.
174 Viewport int
175 RailOverhang int
176 PersonOverhang int
177 // Whether the rail overflows, and — a different question that was
178 // once measured as if it were the same one — whether it SCROLLS.
179 //
180 // RailScroll is scrollHeight - clientHeight: how much content does
181 // not fit. That is a fine reading and a useless assertion, because
182 // overflow: visible reports it too — with overflow-y removed and a
183 // twenty-link rail it still read 371px. So it is the PREMISE now:
184 // it says the fixture is big enough for this leg to mean anything.
185 //
186 // The claim is the three below it. RailOverflowY is what the engine
187 // computed. RailScrolled is where scrollTop actually landed after
188 // the box was asked to go to its own bottom, which a non-scrollable
189 // box clamps to 0. PersonOverhangScrolled is the promise itself:
190 // after that scroll, is the thing below the fold on screen.
191 RailScroll int
192 RailOverflowY string
193 RailScrolled int
194 PersonOverhangScrolled int
195 LocaleBefore bool
196 // The phone index's two: how far the foot sits from the top of the
197 // rail, and where the open card ends.
198 FootFromTop int
199 MenuBottom int
200}
201
202// railMeasure is the one reading every leg of the rail drive takes, so
203// a viewport added below cannot quietly measure less than the ones
204// above it — which is how the frame went missing the first time.
205const railMeasure = `(() => {
206 const rail = document.querySelector("[rst-shell-rail]");
207 const person = document.querySelector("[rst-shell-profile] > summary");
208 const loc = document.querySelector("#rail-locale");
209 const sum = person;
210 const menu = document.querySelector("[rst-shell-profile] > [rst-dropdown-menu]");
211 // Both scrollers wound back to the top first. chromedp.Click scrolls
212 // its target into view, and the locale summary sits at the foot of
213 // the rail — so on a rail that overflows, opening the language menu
214 // has already scrolled something, and every "before" reading below
215 // would be a reading of an already-scrolled box. Which one it
216 // scrolled depends on the very property under test: the rail, if the
217 // rail is a scroll container, and otherwise the whole page. Winding
218 // both back is what makes the two cases comparable.
219 window.scrollTo(0, 0);
220 rail.scrollTop = 0;
221 const r = rail.getBoundingClientRect();
222 const p = person.getBoundingClientRect();
223 const s = sum.getBoundingClientRect();
224 const m = menu.getBoundingClientRect();
225 // Every rect above is taken BEFORE the scroll probe below, because
226 // scrolling the rail moves everything inside it and FootGap is a
227 // reading of where the person sits in an unscrolled rail.
228 const overflowY = getComputedStyle(rail).overflowY;
229 const railScroll = rail.scrollHeight - rail.clientHeight;
230 rail.scrollTop = rail.scrollHeight;
231 const scrolled = Math.round(rail.scrollTop);
232 const pAfter = person.getBoundingClientRect();
233 const personAfter = Math.round(pAfter.bottom - window.innerHeight);
234 rail.scrollTop = 0;
235 return JSON.stringify({
236 RailHeight: Math.round(r.height),
237 FootGap: Math.round(r.bottom - p.bottom),
238 MenuLift: Math.round(s.top - m.bottom),
239 Viewport: window.innerHeight,
240 RailOverhang: Math.round(r.bottom - window.innerHeight),
241 RailScroll: railScroll,
242 RailOverflowY: overflowY,
243 RailScrolled: scrolled,
244 PersonOverhang: Math.round(p.bottom - window.innerHeight),
245 PersonOverhangScrolled: personAfter,
246 LocaleBefore: !!(loc.compareDocumentPosition(document.querySelector("#acct-profile")) & Node.DOCUMENT_POSITION_FOLLOWING),
247 FootFromTop: Math.round(document.querySelector("[rst-shell-rail-foot]").getBoundingClientRect().top - r.top),
248 MenuBottom: Math.round(m.bottom)
249 });
250})()`
251
252// TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward
253// is the other live-page bug: the sidebar shell left the person block
254// mid-rail, directly under the nav, with acres of empty rail below it.
255//
256// Two claims, both measured rather than asserted about the CSS text:
257//
258// 1. The person, now the profile menu's avatar, sits at the rail's
259// foot — the distance from its bottom to the bottom of the rail is
260// small. Before the fix it was most of a 900px window.
261// 2. The profile menu is a DROPUP. Its card's bottom edge is at or
262// above the avatar's top edge; a dropdown's would be below it.
263//
264// The 390 leg measures the phone index, where the rail is the whole
265// page and the same menu sits at the top beside the heading: the foot
266// is at the top of the rail and the open card stays on screen.
267func TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward(t *testing.T) {
268 // The sidebar shell as rastrillo's drive rendered it: a two-link nav,
269 // Grace Hopper as the person, one account link (#acct-profile) and
270 // the language menu (#rail-locale, English and Gaeilge). Three
271 // pages: the content page, the index (the view written as index),
272 // and the tall page below.
273 const drive = "TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward"
274 html := shellPiece(t, drive, "page")
275 indexHTML := shellPiece(t, drive, "index")
276
277 // The same shell with a rail that does not fit a short window, for
278 // the third leg below. A separate page rather than a taller nav on
279 // the one above, because the first two legs assert numbers that a
280 // twenty-link rail would move — "the person is at the foot" and
281 // "the collapsed rail is not stretched" are claims about a rail
282 // with room to spare, and they are the claims worth keeping.
283 //
284 // Twenty links is not decoration: the design system's own rail
285 // carries about thirty, and the leg below exists because a rail
286 // that overflows is the case a two-link fixture can never reach.
287 // The tall page's twenty-link nav (Posts, Drafts, Comments, Media
288 // and on to Settings) is what makes it overflow at 420px, and the
289 // leg FAILS if it stops doing so.
290 tallHTML := shellPiece(t, drive, "tall")
291
292 mux := http.NewServeMux()
293 stylesheets(t, mux)
294 mux.HandleFunc("GET /index", func(w http.ResponseWriter, r *http.Request) {
295 w.Header().Set("Content-Type", "text/html; charset=utf-8")
296 fmt.Fprint(w, indexHTML)
297 })
298 mux.HandleFunc("GET /tall", func(w http.ResponseWriter, r *http.Request) {
299 w.Header().Set("Content-Type", "text/html; charset=utf-8")
300 fmt.Fprint(w, tallHTML)
301 })
302 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
303 w.Header().Set("Content-Type", "text/html; charset=utf-8")
304 fmt.Fprint(w, html)
305 })
306
307 rig := harness.New(t, func(string) http.Handler { return mux })
308 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
309 defer cancel()
310
311 var raw string
312 if err := chromedp.Run(ctx,
313 chromedp.EmulateViewport(1280, 900),
314 chromedp.Navigate(rig.Origin+"/"),
315 chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery),
316 chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery),
317 chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery),
318 chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery),
319 chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery),
320 chromedp.Evaluate(railMeasure, &raw),
321 ); err != nil {
322 t.Fatalf("driving the sidebar rail: %v", err)
323 }
324
325 var got railReading
326 if err := json.Unmarshal([]byte(raw), &got); err != nil {
327 t.Fatalf("reading the measurement (%q): %v", raw, err)
328 }
329
330 // Logged, not just asserted: the number that gave the regression
331 // away was already in this test's passing output — "a 932px rail" at
332 // a 900px viewport — and nobody read it, because nothing in the
333 // output said what it was supposed to be compared to. It says so now.
334 t.Logf("wide: rail %dpx in a %dpx viewport (rail overhang %dpx, person overhang %dpx), person %dpx above the rail's foot",
335 got.RailHeight, got.Viewport, got.RailOverhang, got.PersonOverhang, got.FootGap)
336
337 // Sanity: the rail is the full-height column this claim is about.
338 if got.RailHeight < 600 {
339 t.Fatalf("the rail is only %dpx tall in a 900px window; it is not the sticky full-height rail this drive measures", got.RailHeight)
340 }
341
342 // THE FRAME. Everything below asks where things sit inside the rail;
343 // this asks whether the rail sits inside the window, which is the
344 // question the first version of this drive never put and the one the
345 // regression it missed was an answer to.
346 //
347 // block-size: 100dvh is a promise about the viewport, so the box
348 // that has to keep it is the BORDER box: with the rail as a content
349 // box its padding was added on top, the border box came to
350 // 100dvh + 2×var(--rst-sp-4), and a rail sticky at
351 // inset-block-start: 0 hung its last 32px under the foot of the
352 // window. overflow-y: auto is no defence — the content fits the
353 // content box, so no scrollbar is ever owed — and no assertion about
354 // where the person sits INSIDE the rail can see it.
355 if got.RailHeight > got.Viewport {
356 t.Errorf("the rail's border box is %dpx in a %dpx viewport: it is %dpx taller than the window it is sized against, so whatever sits at its foot is under the fold. box-sizing on the rail is the fix",
357 got.RailHeight, got.Viewport, got.RailHeight-got.Viewport)
358 }
359 if got.RailOverhang > 0 {
360 t.Errorf("the sticky rail's bottom edge is %dpx below the foot of a %dpx window", got.RailOverhang, got.Viewport)
361 }
362 // And the symptom, measured where a reader met it: the person block
363 // at the rail's foot, clipped by the window rather than by the rail.
364 if got.PersonOverhang > 0 {
365 t.Errorf("the person block at the rail's foot ends %dpx below the foot of the window: it is off-screen with nothing to scroll to reach it", got.PersonOverhang)
366 }
367 // A rail's padding is var(--rst-sp-4); anything inside a couple of
368 // steps of the bottom edge is at the foot, anything further is not.
369 if got.FootGap > 48 {
370 t.Errorf("the person block ends %dpx above the foot of a %dpx rail; the profile is not at the rail's foot", got.FootGap, got.RailHeight)
371 }
372 if !got.LocaleBefore {
373 t.Error("the account's links come before the language switcher in the card; the language follows the name and the links close the card")
374 }
375 if got.MenuLift < 0 {
376 t.Errorf("the profile card ends %dpx BELOW the avatar's top edge: it is a dropdown, not the dropup a menu at the foot of the rail has to be", -got.MenuLift)
377 }
378
379 // And the phone index, which replaced the drawer. The rail IS the
380 // page there, at least the height of the window, and the profile
381 // menu is lifted to its top, beside the heading: at the foot of a
382 // long index it would be out of reach. So the foot's distance from
383 // the rail's top is the measurement, and the card, language open,
384 // is asserted to be on screen.
385 var narrow string
386 if err := chromedp.Run(ctx,
387 chromedp.EmulateViewport(390, 780),
388 chromedp.Navigate(rig.Origin+"/index"),
389 chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery),
390 chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery),
391 chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery),
392 chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery),
393 chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery),
394 chromedp.Evaluate(railMeasure, &narrow),
395 ); err != nil {
396 t.Fatalf("driving the phone index: %v", err)
397 }
398 var small railReading
399 if err := json.Unmarshal([]byte(narrow), &small); err != nil {
400 t.Fatalf("reading the index measurement (%q): %v", narrow, err)
401 }
402 t.Logf("index: rail %dpx in a %dpx viewport, foot %dpx from the rail's top", small.RailHeight, small.Viewport, small.FootFromTop)
403 if small.RailHeight < small.Viewport-1 {
404 t.Errorf("the phone index's rail is %dpx in a %dpx window; the rail is the page there", small.RailHeight, small.Viewport)
405 }
406 if small.FootFromTop < 0 || small.FootFromTop > 32 {
407 t.Errorf("the profile menu is %dpx from the top of the index's rail; it belongs in the header row, by the rail's padding", small.FootFromTop)
408 }
409 if small.MenuBottom > small.Viewport {
410 t.Errorf("the profile card on the index ends %dpx below the window", small.MenuBottom-small.Viewport)
411 }
412
413 // And a SHORT window, which is the other half of "fits the
414 // viewport" and the half a desktop reviewer never sees. 1280×420 is
415 // a laptop with the devtools open, or a phone in landscape: wide
416 // enough for the full-height rail, too short for its content.
417 //
418 // The claim here is deliberately weaker than the one above, because
419 // the honest one is weaker: the rail must still fit the window, and
420 // whatever does not fit INSIDE the rail must be reachable by
421 // scrolling it. A person below the fold of a rail that scrolls is
422 // fine; a person below the fold of a rail that does not is the bug
423 // this test is named after, one viewport smaller.
424 //
425 // Two things this leg used to get wrong, and both made it a gate
426 // that gated nothing — removing overflow-y: auto from the rail
427 // passed it green:
428 //
429 // - it ran on the two-link fixture, which fits 420px with room
430 // to spare, so the overflow case it was written for never
431 // arose. It now runs on /tall, and it FAILS if that page stops
432 // overflowing: a leg whose premise has quietly become false is
433 // worse than no leg, because it reads as coverage.
434 // - it measured scrollHeight - clientHeight, which is overflow,
435 // not scrollability: overflow: visible reports it too. It now
436 // asks the engine what it computed, asks the box to scroll to
437 // its own bottom, and asks whether the person came into view.
438 var shortRaw string
439 if err := chromedp.Run(ctx,
440 chromedp.EmulateViewport(1280, 420),
441 chromedp.Navigate(rig.Origin+"/tall"),
442 chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery),
443 chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery),
444 chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery),
445 chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery),
446 chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery),
447 chromedp.Evaluate(railMeasure, &shortRaw),
448 ); err != nil {
449 t.Fatalf("driving the rail in a short window: %v", err)
450 }
451 var short railReading
452 if err := json.Unmarshal([]byte(shortRaw), &short); err != nil {
453 t.Fatalf("reading the short-window measurement (%q): %v", shortRaw, err)
454 }
455 t.Logf("short: rail %dpx in a %dpx viewport (overhang %dpx, %dpx of content over, overflow-y %s), person overhang %dpx before the scroll, %dpx after it, scrollTop %dpx",
456 short.RailHeight, short.Viewport, short.RailOverhang, short.RailScroll, short.RailOverflowY,
457 short.PersonOverhang, short.PersonOverhangScrolled, short.RailScrolled)
458 if short.RailHeight > short.Viewport {
459 t.Errorf("the rail's border box is %dpx in a %dpx viewport: a short window overflows for the same reason a tall one did", short.RailHeight, short.Viewport)
460 }
461 if short.RailOverhang > 0 {
462 t.Errorf("the sticky rail hangs %dpx below the foot of a %dpx window", short.RailOverhang, short.Viewport)
463 }
464 // The premise, asserted before anything that depends on it, and in
465 // terms of the FIXTURE rather than of the property under test: if
466 // the rail's content fits, every assertion below is vacuously true
467 // and this leg is decoration, which is exactly what it was.
468 if short.RailScroll <= 0 {
469 t.Fatalf("the tall rail's content fits inside a %dpx rail (%dpx over): this leg is measuring nothing. Give the tall fixture's nav more links.", short.RailHeight, short.RailScroll)
470 }
471 if short.PersonOverhang <= 0 {
472 t.Fatalf("the person is %dpx ABOVE the fold of a %dpx window before anything scrolls: the case this leg exists for has not arisen. Give the tall fixture's nav more links.", -short.PersonOverhang, short.Viewport)
473 }
474 // It overflows, so it has to scroll. Three readings, because the
475 // one that used to be here could not tell a scrollable box from a
476 // leaking one.
477 if short.RailOverflowY != "auto" && short.RailOverflowY != "scroll" {
478 t.Errorf("the rail computed overflow-y: %s. Its content is %dpx below the fold of a %dpx window with no way to bring it back", short.RailOverflowY, short.PersonOverhang, short.Viewport)
479 }
480 if short.RailScrolled <= 0 {
481 t.Errorf("the rail was asked to scroll to its own bottom and scrollTop stayed at %dpx: it does not scroll, it just overflows", short.RailScrolled)
482 }
483 if short.PersonOverhangScrolled > 0 {
484 t.Errorf("the person block is still %dpx below the window after the rail was scrolled to its bottom: it is unreachable, not merely off-screen", short.PersonOverhangScrolled)
485 }
486}
487
488// barReading is one measurement of the topbar shell's bar: what the
489// collapse control and the tail are doing, how the tail's three blocks
490// are laid out, and whether the bar spills sideways.
491type barReading struct {
492 MenuShown bool // the disclosure's summary is drawn
493 MenuOpen bool // ...and its open attribute is set
494 TailShown bool // nav/account/locale are on the page at all
495 Rows int // distinct block-start positions among the three: 1 = inline, 3 = stacked
496 AccountEndPx int // gap from the end of the account, language and scheme menus to the bar's
497 AccountMenu bool // the account dropdown's panel is drawn
498 Overflow int // how far the document scrolls past its own client width
499 MainTop int // main's block-start edge: the page under the bar
500}
501
502// TestTheTopbarCollapsesItsTailBehindOneDisclosure is §9: the topbar
503// shell had no narrow layout at all. [rst-shell-bar] is a wrapping flex
504// row and [rst-shell-account] has margin-inline-start: auto, so
505// narrowing the window did not collapse anything — it wrapped the
506// account and locale menus onto a second row and shoved them to the
507// trailing edge. Nothing was broken; nothing had been written.
508//
509// Three claims, in the order they matter:
510//
511// 1. Wide, nothing changed. Above 800px the disclosure is not drawn and
512// nav, account and locale sit inline on the bar's own row with the
513// account at the inline end, which is the layout that shipped before
514// the collapse existed.
515// 2. Narrow, one control. Below 800px the tail is not drawn until the
516// disclosure is opened, and opened it is a card over the page: three
517// stacked rows that push nothing down.
518// 3. THE TRAP. The account menu is <details name="rst-menus">. Opening
519// it must not close the navigation it was opened from. <details
520// name> exclusivity is document-wide rather than sibling-scoped, so
521// this is not bought by keeping the two elements out of each other's
522// subtree — the disclosure has to be in a different group, and this
523// is the assertion that says so out loud.
524//
525// There is no script on this page: the shell's <script> tags resolve to
526// addresses this mux does not serve, exactly as the rail drive above
527// leaves them. So everything here is <details>, a media query and two
528// display rules, which is the claim §9 makes.
529func TestTheTopbarCollapsesItsTailBehindOneDisclosure(t *testing.T) {
530 // The topbar shell as rastrillo's drive rendered it: a two-link nav, two
531 // account links, the language menu as #bar-locale, and the scheme
532 // menu the layout ships, which the four-row count below includes.
533 html := shellPiece(t, "TestTheTopbarCollapsesItsTailBehindOneDisclosure", "")
534
535 mux := http.NewServeMux()
536 stylesheets(t, mux)
537 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
538 w.Header().Set("Content-Type", "text/html; charset=utf-8")
539 fmt.Fprint(w, html)
540 })
541
542 rig := harness.New(t, func(string) http.Handler { return mux })
543 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
544 defer cancel()
545
546 const measure = `(() => {
547 const shown = el => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
548 const bar = document.querySelector("[rst-shell-bar]");
549 const menu = document.querySelector("[rst-shell-menu]");
550 const tail = document.querySelector("[rst-shell-tail]");
551 const nav = document.querySelector("[rst-shell-nav]");
552 const account = document.querySelector("[rst-shell-account]");
553 const locale = document.querySelector("#bar-locale");
554 const scheme = document.querySelector("[rst-scheme]");
555 const panel = account.querySelector("[rst-dropdown-menu]");
556 // Rows by overlap, not by equal tops: the bar is align-items:
557 // center, so three inline blocks of three different heights sit on
558 // one row with three different top edges. Two boxes are on the
559 // same row when their vertical ranges overlap.
560 const boxes = [nav, account, locale, scheme].filter(shown)
561 .map(el => el.getBoundingClientRect()).sort((a, b) => a.top - b.top);
562 let rows = 0, edge = -Infinity;
563 for (const b of boxes) {
564 if (b.top >= edge - 1) rows++;
565 if (b.bottom > edge) edge = b.bottom;
566 }
567 const de = document.documentElement;
568 return JSON.stringify({
569 MenuShown: shown(menu.querySelector("summary")),
570 MenuOpen: menu.open,
571 TailShown: shown(nav) || shown(account) || shown(locale) || shown(scheme),
572 Rows: rows,
573 // The account menu's margin-inline-start: auto pushes it and the
574 // language and scheme menus after it to the bar's end, so the gap
575 // is read after the last of the three that is drawn.
576 AccountEndPx: Math.round(bar.getBoundingClientRect().right - Math.max(...[account, locale, scheme].filter(shown).map(el => el.getBoundingClientRect().right))),
577 AccountMenu: shown(panel),
578 Overflow: de.scrollWidth - de.clientWidth,
579 MainTop: Math.round(document.querySelector("main").getBoundingClientRect().top)
580 });
581 })()`
582
583 read := func(t *testing.T, raw string) barReading {
584 t.Helper()
585 var got barReading
586 if err := json.Unmarshal([]byte(raw), &got); err != nil {
587 t.Fatalf("reading the measurement (%q): %v", raw, err)
588 }
589 return got
590 }
591
592 // 1. Wide: the collapse is not there.
593 var wideRaw string
594 if err := chromedp.Run(ctx,
595 chromedp.EmulateViewport(1280, 900),
596 chromedp.Navigate(rig.Origin+"/"),
597 chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery),
598 chromedp.Evaluate(measure, &wideRaw),
599 ); err != nil {
600 t.Fatalf("driving the wide topbar: %v", err)
601 }
602 wide := read(t, wideRaw)
603 if wide.MenuShown {
604 t.Error("the collapse disclosure is drawn at 1280px; above 800px the bar lays its tail out inline and the control has nothing to do")
605 }
606 if !wide.TailShown {
607 t.Fatal("nav, account and locale are not drawn at 1280px at all: the wide layout is gone, not preserved")
608 }
609 if wide.Rows != 1 {
610 t.Errorf("the bar's tail blocks sit on %d rows at 1280px, want 1 — the wide layout is one row and the collapse must not have changed it", wide.Rows)
611 }
612 if wide.AccountEndPx > 120 {
613 t.Errorf("the account, language and scheme menus end %dpx short of the bar's inline end; margin-inline-start: auto is no longer reaching the account menu", wide.AccountEndPx)
614 }
615
616 // 1b. And at exactly 800px, which is the breakpoint itself. A
617 // min-width query matches AT its own width, so 800 is the wide
618 // layout and 799 is the narrow one; an off-by-one here is a window
619 // width at which the bar has neither layout.
620 var edgeRaw string
621 if err := chromedp.Run(ctx,
622 chromedp.EmulateViewport(800, 780),
623 chromedp.Navigate(rig.Origin+"/"),
624 chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery),
625 chromedp.Evaluate(measure, &edgeRaw),
626 ); err != nil {
627 t.Fatalf("driving the topbar at the breakpoint: %v", err)
628 }
629 if edge := read(t, edgeRaw); edge.MenuShown || !edge.TailShown || edge.Rows != 1 {
630 t.Errorf("at exactly 800px the bar shows control=%v tail=%v on %d rows; a min-width query matches at its own width, so 800px is the wide layout",
631 edge.MenuShown, edge.TailShown, edge.Rows)
632 }
633
634 // 2. Narrow: one control, and the tail behind it.
635 var closedRaw, openRaw string
636 if err := chromedp.Run(ctx,
637 chromedp.EmulateViewport(390, 780),
638 chromedp.Navigate(rig.Origin+"/"),
639 chromedp.WaitVisible(`[rst-shell-menu] > summary`, chromedp.ByQuery),
640 chromedp.Evaluate(measure, &closedRaw),
641 chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery),
642 chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-nav]`, chromedp.ByQuery),
643 chromedp.Evaluate(measure, &openRaw),
644 ); err != nil {
645 t.Fatalf("driving the collapsed topbar: %v", err)
646 }
647 closed, open := read(t, closedRaw), read(t, openRaw)
648 if !closed.MenuShown {
649 t.Fatal("there is no collapse control at 390px: the topbar still has no narrow layout")
650 }
651 if closed.TailShown {
652 t.Error("nav, account and locale are still drawn at 390px with the disclosure closed; the tail did not collapse, it only gained a button")
653 }
654 if closed.Overflow > 1 {
655 t.Errorf("the collapsed topbar spills %dpx sideways at 390px", closed.Overflow)
656 }
657 if !open.TailShown {
658 t.Fatal("opening the disclosure at 390px revealed nothing")
659 }
660 if open.Rows != 4 {
661 t.Errorf("the opened tail puts its four blocks (nav, account, language, scheme) on %d rows, want 4 — collapsed, they stack; a wrapped row is the state this replaced", open.Rows)
662 }
663 // The opened tail is a card OVER the page, not a column in the
664 // bar's flow: the page under it does not move.
665 if open.MainTop != closed.MainTop {
666 t.Errorf("opening the disclosure moved the page down %dpx; the tail is a card that overlays it", open.MainTop-closed.MainTop)
667 }
668 if open.Overflow > 1 {
669 t.Errorf("the opened topbar spills %dpx sideways at 390px", open.Overflow)
670 }
671
672 // 3. The trap: opening the account menu must not close the
673 // navigation it was opened from.
674 var trapRaw string
675 if err := chromedp.Run(ctx,
676 chromedp.Click(`[rst-shell-account] > summary`, chromedp.ByQuery),
677 // Settle rather than WaitVisible: the failure this step exists
678 // for is the account menu never becoming visible, because it
679 // closed the disclosure it lives behind. Waiting for it would
680 // report that as a timeout instead of as the three sentences
681 // below, and a timeout says nothing about <details name>.
682 chromedp.Sleep(250*time.Millisecond),
683 chromedp.Evaluate(measure, &trapRaw),
684 ); err != nil {
685 t.Fatalf("opening the account menu inside the collapsed tail: %v", err)
686 }
687 trap := read(t, trapRaw)
688 if !trap.MenuOpen {
689 t.Error("opening the account menu CLOSED the disclosure it sits behind: the two <details> are in one exclusivity group, and <details name> exclusivity is document-wide rather than sibling-scoped. Give the disclosure a group of its own")
690 }
691 if !trap.TailShown {
692 t.Error("the tail vanished when the account menu opened; the navigation a reader opened the menu from is gone from under them")
693 }
694 if !trap.AccountMenu {
695 t.Error("the account menu's panel is not drawn inside the collapsed tail")
696 }
697
698 // 4. And the smallest viewport the shells promise, with the tail
699 // open, because a stack that fits at 390px can still spill at 320.
700 var tinyRaw string
701 if err := chromedp.Run(ctx,
702 chromedp.EmulateViewport(320, 640),
703 chromedp.Navigate(rig.Origin+"/"),
704 chromedp.WaitVisible(`[rst-shell-menu] > summary`, chromedp.ByQuery),
705 chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery),
706 chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-nav]`, chromedp.ByQuery),
707 chromedp.Evaluate(measure, &tinyRaw),
708 ); err != nil {
709 t.Fatalf("driving the collapsed topbar at 320px: %v", err)
710 }
711 if tiny := read(t, tinyRaw); tiny.Overflow > 1 {
712 t.Errorf("the opened topbar spills %dpx sideways at 320px", tiny.Overflow)
713 }
714}
715
716// cornerReading is one element's four logical corner radii, as strings
717// straight off getComputedStyle so a comparison is exact rather than
718// parsed-and-rounded.
719type cornerReading struct {
720 StartStart string
721 StartEnd string
722 EndStart string
723 EndEnd string
724}
725
726func (c cornerReading) String() string {
727 return c.StartStart + "/" + c.StartEnd + " " + c.EndStart + "/" + c.EndEnd
728}
729
730func (c cornerReading) uniform() bool {
731 return c.StartStart == c.StartEnd && c.StartStart == c.EndStart && c.StartStart == c.EndEnd
732}
733
734// TestSelfShapedChildrenKeepTheirCornersInsideACard gates the cascade
735// relationship the corner-rounding rules depend on, which a comment
736// cannot: the rules are wrapped in :where() so they weigh (0,0,0), and
737// therefore lose to ANY child that declares a radius of its own.
738//
739// Written before it was true. A bare `[rst-list] > :first-child` weighs
740// (0,2,0) and beats [rst-search]'s and [rst-empty]'s (0,1,0) in every
741// source order, so a hand-written <form rst-search> as the
742// direct first child of a list card painted 7px on its top corners and
743// 6px on its bottom ones in day — lopsided, and the exact arrangement
744// ui/partials/list-bar-search.html exists to support. The gallery does
745// not show it, because list-bar-search renders inside a <search>
746// landmark and the direct child is that unpainted element, so this gate
747// is built on bare markup rather than on a rendered page.
748//
749// The assertion is deliberately theme-independent and token-independent:
750// the SAME element is rendered inside a card and outside one, and the
751// two readings must agree. No pixel value appears in this test, so a
752// theme that changes its radii cannot make it stale. Every theme is
753// measured, because --rst-radius and --rst-radius-sm differ between them
754// and a rule that ties in one could win in another.
755func TestSelfShapedChildrenKeepTheirCornersInsideACard(t *testing.T) {
756 // list-bar-search as rastrillo rendered it, with /posts as its action.
757 searchForm := shellPiece(t, "TestSelfShapedChildrenKeepTheirCornersInsideACard", "list-bar-search")
758 // The partial wraps its form in the <search> landmark. Strip that
759 // wrapper: the bare form as a card's direct child is the case under
760 // test, and it is the case an app hand-writes.
761 bare := searchForm
762 if i, j := strings.Index(bare, "<form"), strings.LastIndex(bare, "</form>"); i >= 0 && j > i {
763 bare = bare[i : j+len("</form>")]
764 } else {
765 t.Fatalf("list-bar-search no longer renders a <form>: %q", searchForm)
766 }
767 empty := `<div rst-empty><p>Nothing here yet.</p></div>`
768 row := `<div rst-row><span>A row</span></div>`
769
770 // Each case: a self-shaped element in the position under test
771 // inside a card, and the same element loose on the page. Plus the
772 // negative control — a plain row, which declares no radius and so
773 // MUST take the card's corners, or the rule has stopped working
774 // altogether and the rest of this test would pass vacuously.
775 cases := []struct {
776 name, inCard, loose string
777 wantOwn bool
778 }{
779 {"a bare search form as the first child", `<div rst-list>` + bare + row + `</div>`, bare, true},
780 {"an empty state as the last child", `<div rst-list>` + row + empty + `</div>`, empty, true},
781 {"a plain row as the first child", `<div rst-list>` + row + row + `</div>`, row, false},
782 }
783
784 var body strings.Builder
785 for i, c := range cases {
786 fmt.Fprintf(&body, `<div id="in-%d" rst-page>%s</div><div id="out-%d" rst-page>%s</div>`, i, c.inCard, i, c.loose)
787 }
788 pageHTML := body.String()
789
790 mux := http.NewServeMux()
791 mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) {
792 w.Header().Set("Content-Type", "text/css")
793 w.Write(fiesta.TokensCSS())
794 })
795 // One route per theme, named by ThemeNames() rather than hardcoded:
796 // a fourth theme has to appear here without an edit.
797 for _, name := range fiesta.ThemeNames() {
798 css, ok := fiesta.ThemeCSS(name)
799 if !ok {
800 t.Fatalf("ThemeCSS(%q) reports no such theme", name)
801 }
802 mux.HandleFunc("GET /theme-"+name+".css", func(w http.ResponseWriter, r *http.Request) {
803 w.Header().Set("Content-Type", "text/css")
804 w.Write(css)
805 })
806 }
807 mux.HandleFunc("GET /{theme}/", func(w http.ResponseWriter, r *http.Request) {
808 w.Header().Set("Content-Type", "text/html; charset=utf-8")
809 fmt.Fprintf(w, `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>corners</title>`+
810 `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme-%s.css">`+
811 `</head><body>%s</body></html>`, r.PathValue("theme"), pageHTML)
812 })
813
814 rig := harness.New(t, func(string) http.Handler { return mux })
815 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
816 defer cancel()
817
818 const measure = `(() => {
819 const read = el => {
820 const s = getComputedStyle(el);
821 return {
822 StartStart: s.borderStartStartRadius, StartEnd: s.borderStartEndRadius,
823 EndStart: s.borderEndStartRadius, EndEnd: s.borderEndEndRadius
824 };
825 };
826 const out = [];
827 let i = 0;
828 for (;;) {
829 const inCard = document.querySelector("#in-" + i);
830 const loose = document.querySelector("#out-" + i);
831 if (!inCard || !loose) break;
832 // The element under test is the first child of the card for the
833 // first-child cases and the last for the last-child ones; the
834 // loose copy is the page div's only child either way.
835 const probe = loose.firstElementChild;
836 // The identity of the element under test. It used to be the class
837 // list; the vocabulary is attributes now, so an empty state and a
838 // row both read as "DIV." and the wrong child was measured. The
839 // attribute NAMES are the signature — never their values, so a
840 // variant cannot change what matches — with id left out, because
841 // that is what tells the two copies apart.
842 const sig = el => el.tagName + "|" +
843 [...el.attributes].map(a => a.name).filter(n => n !== "id").sort().join(",");
844 const tag = sig(probe);
845 const card = inCard.firstElementChild;
846 let mine = null;
847 for (const kid of card.children) {
848 if (mine === null && sig(kid) === tag) { mine = kid; }
849 }
850 out.push({ In: mine ? read(mine) : null, Out: read(probe) });
851 i++;
852 }
853 return JSON.stringify(out);
854 })()`
855
856 for _, theme := range fiesta.ThemeNames() {
857 var raw string
858 if err := chromedp.Run(ctx,
859 chromedp.EmulateViewport(1280, 900),
860 chromedp.Navigate(rig.Origin+"/"+theme+"/"),
861 chromedp.WaitVisible(`#in-0`, chromedp.ByQuery),
862 chromedp.Evaluate(measure, &raw),
863 ); err != nil {
864 t.Fatalf("%s: driving the corner probe: %v", theme, err)
865 }
866 var got []struct{ In, Out *cornerReading }
867 if err := json.Unmarshal([]byte(raw), &got); err != nil {
868 t.Fatalf("%s: reading the measurement (%q): %v", theme, raw, err)
869 }
870 if len(got) != len(cases) {
871 t.Fatalf("%s: measured %d cases, want %d", theme, len(got), len(cases))
872 }
873 for i, c := range cases {
874 name := theme + ": " + c.name
875 if got[i].In == nil {
876 t.Errorf("%s: the element was not found inside the card", name)
877 continue
878 }
879 in, out := *got[i].In, *got[i].Out
880 if c.wantOwn {
881 if in != out {
882 t.Errorf("%s: it computes %s inside a card and %s outside one — the card's corner rule is out-weighing the child's own radius", name, in, out)
883 }
884 // The symptom a reader sees is lopsidedness, so name it
885 // as well as the mismatch: a self-shaped child that
886 // keeps one pair of corners and loses the other reads
887 // as a rendering bug, not as a cascade one.
888 if !in.uniform() {
889 t.Errorf("%s: its corners are %s inside a card — lopsided, so only some of them lost the cascade", name, in)
890 }
891 continue
892 }
893 // The negative control. Without it every assertion above
894 // would also pass if the corner rules were simply deleted.
895 if in == out {
896 t.Errorf("%s: it computes the same %s inside a card as outside one; the card is no longer rounding the rows that have no radius of their own", name, in)
897 }
898 if in.StartStart == "0px" {
899 t.Errorf("%s: it computes %s inside a card — the card's own corner rule is not reaching a full-bleed row", name, in)
900 }
901 }
902 }
903}
904
905// menuCorner is one corner of the window with a twelve-item menu in it —
906// the shape of the bug §6-v2.1b was written about, since the language
907// menu is exactly twelve entries and measures 388px.
908type menuCorner struct {
909 id string
910 style string
911 // short cuts the menu to three items and mid says it sits in open
912 // window with room on every side. They go together, and they exist
913 // for one reason: a corner fixture cannot tell a right default from
914 // a wrong one. flip-block is symmetric, so a stylesheet that opened
915 // every menu UPWARD by default would be flipped back to correct in
916 // all four corners and the drive would stay green while every
917 // ordinary page in every app dropped its menus the wrong way. That
918 // mutation was run against the first version of this drive and it
919 // passed. The mid fixture is what fails it.
920 short bool
921 mid bool
922}
923
924// Each corner's menu is the piece rastrillo's drive rendered for it: the
925// dropdown partial with twelve entries, except bl, which is the
926// hand-written row menu (so the drive covers BOTH panels the rule
927// names), and mid, which has three.
928var menuCorners = []menuCorner{
929 {id: "tl", style: "inset-block-start:8px;inset-inline-start:8px"},
930 {id: "tr", style: "inset-block-start:8px;inset-inline-end:8px"},
931 {id: "bl", style: "inset-block-end:8px;inset-inline-start:8px"},
932 {id: "br", style: "inset-block-end:8px;inset-inline-end:8px"},
933 {id: "mid", style: "inset-block-start:50%;inset-inline-start:40%", short: true, mid: true},
934}
935
936// menuReading is one opened menu, measured against the window it opened
937// in. Every field is here because an assertion below needs it AND
938// because a failure is unreadable without it: "the menu did not flip"
939// says nothing without the two rectangles that prove it.
940type menuReading struct {
941 SummaryTop, SummaryBottom int
942 SummaryLeft, SummaryRight int
943 MenuTop, MenuBottom int
944 MenuLeft, MenuRight int
945 ScrollHeight, ClientHeight int
946 MaxBlockSize, OverflowY string
947 SupportsArea, SupportsTry bool
948 SupportsScope, SupportsNonsense bool
949 SupportsControl bool
950 ViewportW, ViewportH int
951 Direction string
952}
953
954// menuMeasure opens every menu on the page and reads it back. The
955// capability probes ride along with the geometry deliberately: they are
956// read from the same engine in the same run, so a failure names both
957// halves at once.
958const menuMeasure = `(() => {
959 const out = {};
960 for (const box of document.querySelectorAll('[data-corner]')) {
961 const d = box.querySelector('details');
962 d.open = true;
963 const s = d.querySelector('summary').getBoundingClientRect();
964 const m = d.querySelector('[rst-dropdown-menu], [rst-row-menu-panel]');
965 const r = m.getBoundingClientRect();
966 const cs = getComputedStyle(m);
967 out[box.dataset.corner] = {
968 SummaryTop: Math.round(s.top), SummaryBottom: Math.round(s.bottom),
969 SummaryLeft: Math.round(s.left), SummaryRight: Math.round(s.right),
970 MenuTop: Math.round(r.top), MenuBottom: Math.round(r.bottom),
971 MenuLeft: Math.round(r.left), MenuRight: Math.round(r.right),
972 ScrollHeight: m.scrollHeight, ClientHeight: m.clientHeight,
973 MaxBlockSize: cs.maxBlockSize, OverflowY: cs.overflowY,
974 SupportsArea: CSS.supports('position-area: block-end'),
975 SupportsTry: CSS.supports('position-try-fallbacks: flip-block'),
976 SupportsScope: CSS.supports('anchor-scope: --rst-menu'),
977 SupportsNonsense: CSS.supports('position-area: rastrillo-no-such-value'),
978 SupportsControl: CSS.supports('color: red'),
979 ViewportW: window.innerWidth, ViewportH: window.innerHeight,
980 Direction: getComputedStyle(document.documentElement).direction,
981 };
982 }
983 return JSON.stringify(out);
984})()`
985
986// TestMenusCapTheirHeightScrollAndFlipToFitTheViewport is design spec
987// §6-v2.1b, sub-sections 1 and 2, in one drive because they are one
988// surface: [rst-dropdown-menu] and [rst-row-menu-panel] had no height
989// cap, no scroll, and no idea what was underneath them.
990//
991// THE CAP AND THE SCROLL are the everywhere half. A twelve-locale
992// language menu is 388px; on a short window its last entries were
993// simply unreachable, with nothing to drag. The drive requires the menu
994// to be really scrolling — scrollHeight above clientHeight — so a
995// future change that makes the fixture short enough to fit fails here
996// rather than quietly measuring nothing.
997//
998// THE FLIP is CSS anchor positioning, and it is Chromium-only today.
999// This drive runs Chromium, so it does not ask permission: the flip is
1000// asserted flat, and the four capability probes are asserted alongside
1001// it. That combination is the point, and it is what stops this being
1002// the tenth gate on this branch that gated nothing:
1003//
1004// - The geometry is asserted unconditionally. If the properties are
1005// renamed, dropped, or land behind a flag, the menu stops flipping
1006// and this goes red. There is no `if supported` for it to fall
1007// through.
1008// - The probes are asserted TRUE for the exact declarations
1009// tokens.css writes. A rename that CSS.supports starts answering
1010// false to fails here too, which is what tells the next reader
1011// WHY the geometry broke.
1012// - The probe is controlled in both directions: a nonsense
1013// position-area value must be answered FALSE and `color: red` must
1014// be answered TRUE. A CSS.supports that has started agreeing with
1015// everything, or refusing everything, cannot make this test green.
1016// - The fixture is proved to NEED the flip before the flip is
1017// asserted: the bottom corners are checked to have less room below
1018// the summary than the menu is tall. Without that a taller window
1019// would pass this test with no flip happening at all.
1020//
1021// RTL is a second pass over the same fixture, since every declaration
1022// involved is a logical one and the inline flip is the half that would
1023// give that away.
1024func TestMenusCapTheirHeightScrollAndFlipToFitTheViewport(t *testing.T) {
1025 // Twelve, because twelve locales is the menu that broke. The
1026 // autonyms are transliterated in the seeded pieces rather than in
1027 // their own scripts: this drive measures boxes, and a font fallback
1028 // that changed a glyph's width would move a number nobody meant to
1029 // move. The mid corner's piece has three entries, so that fixture
1030 // has room on every side and the default drop direction is what
1031 // shows: twelve would not fit above AND below a 620px window, and
1032 // a fixture that only fits one way is a corner again.
1033 render := func(dir string) string {
1034 var b strings.Builder
1035 b.WriteString(`<!doctype html><html lang="en" dir="` + dir + `"><head><meta charset="utf-8">` +
1036 `<title>menus</title><link rel="stylesheet" href="/tokens.css">` +
1037 `<link rel="stylesheet" href="/theme.css"></head><body>`)
1038 for _, c := range menuCorners {
1039 b.WriteString(`<div data-corner="` + c.id + `" style="position:fixed;` + c.style + `">`)
1040 b.WriteString(shellPiece(t, "TestMenusCapTheirHeightScrollAndFlipToFitTheViewport", c.id))
1041 b.WriteString(`</div>`)
1042 }
1043 b.WriteString(`</body></html>`)
1044 return b.String()
1045 }
1046
1047 ltr, rtl := render("ltr"), render("rtl")
1048 mux := http.NewServeMux()
1049 stylesheets(t, mux)
1050 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
1051 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1052 fmt.Fprint(w, ltr)
1053 })
1054 mux.HandleFunc("GET /rtl", func(w http.ResponseWriter, r *http.Request) {
1055 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1056 fmt.Fprint(w, rtl)
1057 })
1058
1059 rig := harness.New(t, func(string) http.Handler { return mux })
1060 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
1061 defer cancel()
1062
1063 for _, leg := range []struct{ name, path string }{{"ltr", "/"}, {"rtl", "/rtl"}} {
1064 var raw string
1065 if err := chromedp.Run(ctx,
1066 // 620px is short on purpose: it is the window in which a
1067 // 388px menu opened from either bottom corner has nowhere
1068 // to go, which is the whole subject.
1069 chromedp.EmulateViewport(760, 620),
1070 chromedp.Navigate(rig.Origin+leg.path),
1071 chromedp.WaitVisible(`[data-corner="tl"] details`, chromedp.ByQuery),
1072 chromedp.Evaluate(menuMeasure, &raw),
1073 ); err != nil {
1074 t.Fatalf("%s: driving the menus: %v", leg.name, err)
1075 }
1076 var got map[string]menuReading
1077 if err := json.Unmarshal([]byte(raw), &got); err != nil {
1078 t.Fatalf("%s: reading the measurement (%q): %v", leg.name, raw, err)
1079 }
1080
1081 for _, c := range menuCorners {
1082 m, ok := got[c.id]
1083 if !ok {
1084 t.Fatalf("%s/%s: the menu was never measured", leg.name, c.id)
1085 }
1086 where := leg.name + "/" + c.id
1087 t.Logf("%s: summary [%d,%d]-[%d,%d], menu [%d,%d]-[%d,%d] in %dx%d; %d of %dpx shown, max-block-size %s",
1088 where, m.SummaryLeft, m.SummaryTop, m.SummaryRight, m.SummaryBottom,
1089 m.MenuLeft, m.MenuTop, m.MenuRight, m.MenuBottom,
1090 m.ViewportW, m.ViewportH, m.ClientHeight, m.ScrollHeight, m.MaxBlockSize)
1091
1092 // THE PROBE, AND THE PROBE'S OWN CONTROL. Read first,
1093 // because everything below it is meaningless if CSS.supports
1094 // has stopped discriminating.
1095 if !m.SupportsControl {
1096 t.Fatalf("%s: CSS.supports says this engine does not support `color: red`; the probe itself is broken, so nothing this drive reports about anchor positioning can be believed", where)
1097 }
1098 if m.SupportsNonsense {
1099 t.Fatalf("%s: CSS.supports agreed to a position-area value that does not exist; it is answering true to everything, so a support check here would be worthless", where)
1100 }
1101 if !m.SupportsArea || !m.SupportsTry || !m.SupportsScope {
1102 t.Fatalf("%s: this engine reports position-area %v, position-try-fallbacks %v, anchor-scope %v. tokens.css writes all three; if one has been renamed the menus below have silently stopped flipping", where, m.SupportsArea, m.SupportsTry, m.SupportsScope)
1103 }
1104
1105 // THE CAP AND THE SCROLL.
1106 if m.OverflowY != "auto" && m.OverflowY != "scroll" {
1107 t.Errorf("%s: the menu computes overflow-y: %s, so a menu taller than the window still cannot be scrolled", where, m.OverflowY)
1108 }
1109 switch {
1110 case c.short && m.ScrollHeight > m.ClientHeight:
1111 // The mid fixture's three entries have to FIT, or it is
1112 // not the "room on every side" fixture the default
1113 // direction is read off below.
1114 t.Errorf("%s: the three-item menu is scrolling — %d of %dpx shown — so it no longer has room on every side and cannot pin the default direction", where, m.ClientHeight, m.ScrollHeight)
1115 case !c.short && m.ScrollHeight <= m.ClientHeight:
1116 t.Errorf("%s: the menu shows all %dpx of its %dpx of entries, so this fixture is not tall enough to prove anything about the cap. Add entries or shorten the window.", where, m.ClientHeight, m.ScrollHeight)
1117 }
1118 if height := m.MenuBottom - m.MenuTop; height > m.ViewportH {
1119 t.Errorf("%s: the menu is %dpx tall in a %dpx window — the cap is not holding", where, height, m.ViewportH)
1120 }
1121
1122 // IN VIEW AT ALL. The plainest statement of what Paul
1123 // asked for, and the one that fails whichever of the two
1124 // flips has stopped working.
1125 if m.MenuTop < -1 || m.MenuLeft < -1 || m.MenuBottom > m.ViewportH+1 || m.MenuRight > m.ViewportW+1 {
1126 t.Errorf("%s: the menu sits at [%d,%d]-[%d,%d], outside the %dx%d window", where,
1127 m.MenuLeft, m.MenuTop, m.MenuRight, m.MenuBottom, m.ViewportW, m.ViewportH)
1128 }
1129
1130 // THE BLOCK AXIS, and first the proof that this fixture is
1131 // the one it claims to be. Without those checks a taller
1132 // window would make the corner assertions pass with nothing
1133 // flipping, and a mid fixture that had drifted into a
1134 // corner would stop pinning anything.
1135 menuHeight := m.MenuBottom - m.MenuTop
1136 above, below := m.SummaryTop, m.ViewportH-m.SummaryBottom
1137 switch {
1138 case c.mid:
1139 // THE DEFAULT DIRECTION. A corner fixture cannot assert
1140 // this: flip-block is symmetric, so a stylesheet that
1141 // preferred block-start would be flipped back to
1142 // correct in every corner and this drive would stay
1143 // green while every ordinary page opened its menus
1144 // upward. Here there is room both ways, so what shows
1145 // IS the default.
1146 if above < menuHeight+4 || below < menuHeight+4 {
1147 t.Fatalf("%s: the menu is %dpx tall with %dpx above the summary and %dpx below. This fixture is supposed to have room on BOTH sides — with room on only one, flip-block rescues a wrong default and the assertion below stops meaning anything.", where, menuHeight, above, below)
1148 }
1149 if m.MenuTop < m.SummaryBottom-1 {
1150 t.Errorf("%s: the menu's head is at %d, above the summary's foot at %d — with room on both sides a menu must drop DOWNWARD; the default direction has been inverted", where, m.MenuTop, m.SummaryBottom)
1151 }
1152 // The same hole on the inline axis: with room both ways
1153 // the default alignment is what shows, and it is
1154 // span-inline-start — the panel's inline-END edge on
1155 // the summary's inline-end edge, which is what
1156 // inset-inline-end: 0 always did.
1157 gotEdge, wantEdge := m.MenuRight, m.SummaryRight
1158 if m.Direction == "rtl" {
1159 gotEdge, wantEdge = m.MenuLeft, m.SummaryLeft
1160 }
1161 if gotEdge < wantEdge-1 || gotEdge > wantEdge+1 {
1162 t.Errorf("%s: the menu's inline-end edge is at %d and the summary's is at %d (dir=%s) — with room on both sides the default alignment has changed", where, gotEdge, wantEdge, m.Direction)
1163 }
1164 case strings.HasPrefix(c.id, "b"):
1165 if below >= menuHeight {
1166 t.Fatalf("%s: there is %dpx under the summary and the menu is %dpx tall, so it fits below and no flip is required. This fixture no longer tests the flip.", where, below, menuHeight)
1167 }
1168 if m.MenuBottom > m.SummaryTop+1 {
1169 t.Errorf("%s: the menu's foot is at %d and the summary's head is at %d — it opened downward off the bottom of the window instead of flipping up", where, m.MenuBottom, m.SummaryTop)
1170 }
1171 default:
1172 if m.MenuTop < m.SummaryBottom-1 {
1173 t.Errorf("%s: the menu's head is at %d, above the summary's foot at %d — it flipped upward where there was room below", where, m.MenuTop, m.SummaryBottom)
1174 }
1175 }
1176 }
1177 }
1178}
1179
1180// TestTheScrollbarGutterHoldsTheLayoutStill is design spec §6-v2.1b.4:
1181// clicking between a short screen and a long one moved the whole page
1182// sideways by the scrollbar's width, and opening a modal did the same
1183// thing in the other direction because its scroll lock takes the
1184// scrollbar away.
1185//
1186// The control is the whole drive. --rst-scrollbar-gutter: auto is the
1187// documented opt-out, so the same fixture with the token overridden is
1188// the page as it was before the fix, and it MUST show the shift. If it
1189// does not, this browser is drawing overlay scrollbars — the macOS
1190// default, and chromedp's headless default until harness.WithScrollbars
1191// turns it off — and the drive fails saying exactly that rather than
1192// reporting a fix it never saw.
1193func TestTheScrollbarGutterHoldsTheLayoutStill(t *testing.T) {
1194 // A ruler and, for the tall pages, something to scroll. The ruler
1195 // is a width: 100% block and body's own first child, so it reports
1196 // the inline space the layout actually got and nothing in between
1197 // can absorb a difference.
1198 //
1199 // document.documentElement.clientWidth is NOT that number, and was
1200 // the first thing this drive tried: Chromium reports the full window
1201 // width there whether or not a gutter is reserved, so every
1202 // measurement agreed with every other one and the drive proved
1203 // nothing at all.
1204 const ruler = `<div id="ruler" style="width:100%;height:4px"></div>`
1205 page := func(extra, body string) string {
1206 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>gutter</title>` +
1207 `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme.css">` + extra +
1208 `</head><body>` + ruler + body + `</body></html>`
1209 }
1210 const optOut = `<style>:root{--rst-scrollbar-gutter:auto}</style>`
1211 const tall = `<div style="height:3000px"></div>`
1212
1213 mux := http.NewServeMux()
1214 stylesheets(t, mux)
1215 for path, html := range map[string]string{
1216 "/short": page("", ""),
1217 "/tall": page("", tall),
1218 "/short-auto": page(optOut, ""),
1219 "/tall-auto": page(optOut, tall),
1220 } {
1221 html := html
1222 mux.HandleFunc("GET "+path, func(w http.ResponseWriter, r *http.Request) {
1223 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1224 fmt.Fprint(w, html)
1225 })
1226 }
1227
1228 rig := harness.New(t, func(string) http.Handler { return mux }, harness.WithScrollbars())
1229 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
1230 defer cancel()
1231
1232 // The modal half is measured on one page rather than two, by adding
1233 // the backdrop and reading the ruler again: nothing else about the
1234 // document changes between the two numbers, which is the only way
1235 // the difference can be attributed to the scroll lock.
1236 const measure = `(() => {
1237 const w = () => document.getElementById('ruler').getBoundingClientRect().width;
1238 const before = w();
1239 const b = document.createElement('div');
1240 // The attribute, which is what the gallery and every partial emit.
1241 // It used to be a class, and tokens.css pairs both until stage 3 —
1242 // so this went on passing while measuring a selector nothing writes.
1243 b.setAttribute('rst-backdrop', '');
1244 document.body.appendChild(b);
1245 const after = w();
1246 b.remove();
1247 return JSON.stringify({before, after});
1248})()`
1249
1250 read := func(path string) (width int) {
1251 t.Helper()
1252 var raw string
1253 if err := chromedp.Run(ctx,
1254 chromedp.EmulateViewport(900, 600),
1255 chromedp.Navigate(rig.Origin+path),
1256 chromedp.WaitVisible(`#ruler`, chromedp.ByQuery),
1257 chromedp.Evaluate(`JSON.stringify(Math.round(document.getElementById('ruler').getBoundingClientRect().width))`, &raw),
1258 ); err != nil {
1259 t.Fatalf("measuring %s: %v", path, err)
1260 }
1261 if err := json.Unmarshal([]byte(raw), &width); err != nil {
1262 t.Fatalf("reading %s (%q): %v", path, raw, err)
1263 }
1264 return width
1265 }
1266
1267 short, tallW := read("/short"), read("/tall")
1268 shortAuto, tallAuto := read("/short-auto"), read("/tall-auto")
1269 t.Logf("stable: short %dpx, long %dpx — auto: short %dpx, long %dpx", short, tallW, shortAuto, tallAuto)
1270
1271 // THE CONTROL, first, because every assertion after it depends on
1272 // this browser being able to show the bug at all.
1273 if shortAuto == tallAuto {
1274 t.Fatalf("with --rst-scrollbar-gutter: auto a short page and a long one both measure %dpx. This browser's scrollbars take no layout space, so nothing here can tell a working gutter from a missing one — check harness.WithScrollbars still reaches the browser.", shortAuto)
1275 }
1276
1277 // THE FIX.
1278 if short != tallW {
1279 t.Errorf("a short page measures %dpx and a long one %dpx: the layout still slides by %dpx when the scrollbar comes and goes", short, tallW, short-tallW)
1280 }
1281
1282 // THE SECOND BUG, which nobody had attributed to the scrollbar: the
1283 // modal's scroll lock removes it, and the page jumps sideways while
1284 // the reader is watching.
1285 var raw string
1286 if err := chromedp.Run(ctx,
1287 chromedp.EmulateViewport(900, 600),
1288 chromedp.Navigate(rig.Origin+"/tall"),
1289 chromedp.WaitVisible(`#ruler`, chromedp.ByQuery),
1290 chromedp.Evaluate(measure, &raw),
1291 ); err != nil {
1292 t.Fatalf("driving the modal scroll lock: %v", err)
1293 }
1294 var stable struct{ Before, After float64 }
1295 if err := json.Unmarshal([]byte(raw), &stable); err != nil {
1296 t.Fatalf("reading the modal measurement (%q): %v", raw, err)
1297 }
1298 if err := chromedp.Run(ctx,
1299 chromedp.Navigate(rig.Origin+"/tall-auto"),
1300 chromedp.WaitVisible(`#ruler`, chromedp.ByQuery),
1301 chromedp.Evaluate(measure, &raw),
1302 ); err != nil {
1303 t.Fatalf("driving the modal scroll lock without the gutter: %v", err)
1304 }
1305 var auto struct{ Before, After float64 }
1306 if err := json.Unmarshal([]byte(raw), &auto); err != nil {
1307 t.Fatalf("reading the modal control (%q): %v", raw, err)
1308 }
1309 t.Logf("modal scroll lock: stable %.0f→%.0fpx, auto %.0f→%.0fpx", stable.Before, stable.After, auto.Before, auto.After)
1310
1311 if auto.Before == auto.After {
1312 t.Fatalf("without the gutter, opening a modal left the page %vpx wide either way — the scroll lock is not removing a scrollbar here, so this leg is measuring nothing", auto.Before)
1313 }
1314 if stable.Before != stable.After {
1315 t.Errorf("opening a modal took the page from %vpx to %vpx: body:has([rst-backdrop]) { overflow: hidden } is still yanking the scrollbar out from under the layout", stable.Before, stable.After)
1316 }
1317}
1318
1319// clearReading is the clear affordance measured where a finger lands:
1320// its own box, and what is actually on top at the middle of it.
1321type clearReading struct {
1322 W, H float64
1323 Href string
1324 HitIsTheClear bool
1325 Hit string
1326 InputRight float64
1327 InputTop float64
1328 InputBottom float64
1329 FormLeft float64
1330 FormRight float64
1331 ClearLeft float64
1332 ClearRight float64
1333}
1334
1335const clearMeasure = `(() => {
1336 const form = document.querySelector('[rst-search]');
1337 const input = form.querySelector('input[type=search]');
1338 const a = form.querySelector('[rst-search-clear]');
1339 const r = a.getBoundingClientRect(), ir = input.getBoundingClientRect(), fr = form.getBoundingClientRect();
1340 const hit = document.elementFromPoint(r.left + r.width/2, r.top + r.height/2);
1341 return JSON.stringify({
1342 W: Math.round(r.width*100)/100, H: Math.round(r.height*100)/100,
1343 Href: a.getAttribute('href'),
1344 HitIsTheClear: !!(hit && hit.closest('[rst-search-clear]')),
1345 Hit: hit ? hit.tagName + '.' + (hit.getAttribute('class') || '') : 'nothing',
1346 InputRight: ir.right, InputTop: ir.top, InputBottom: ir.bottom,
1347 FormLeft: fr.left, FormRight: fr.right,
1348 ClearLeft: r.left, ClearRight: r.right,
1349 });
1350})()`
1351
1352// TestClearingASearchIsALinkAndTheNativeCrossIsGone is design spec
1353// §6-v2.1b.6, the bug Paul screenshotted: a search field holding "sere",
1354// a ✕ beside it, and clicking the ✕ left the results and the ?q= exactly
1355// where they were.
1356//
1357// That ✕ was ::-webkit-search-cancel-button doing precisely what it is
1358// specified to do — clearing the input's VALUE — in a GET form that only
1359// submits on submit. list-bar-search now renders a real link instead,
1360// and tokens.css takes the native one away, because two affordances one
1361// of which lies is worse than one that works.
1362//
1363// Three things are asserted, and the second is the one with teeth:
1364//
1365// 1. The link goes where clearing a search should go: the same screen
1366// with q dropped and the carried filter kept.
1367// 2. The native ✕ is really gone. This is proved against a CONTROL
1368// page — a bare <input type="search"> with none of this library's
1369// CSS on it — which is clicked in the same place and MUST clear.
1370// If Chromium ever stops drawing that button, the control fails and
1371// says so, instead of the subject passing because there was never
1372// anything there to suppress.
1373// 3. The target is at least 24×24 CSS px, WCAG 2.2 SC 2.5.8 (AA), and
1374// is really on top at its own centre — a 24px box under something
1375// else is not a 24px target.
1376//
1377// RTL is a second leg: the ✕ has to stay inside the field box when the
1378// field mirrors, which is a claim about logical properties and nothing
1379// else.
1380func TestClearingASearchIsALinkAndTheNativeCrossIsGone(t *testing.T) {
1381 // list-bar-search as rastrillo rendered it: action /posts, the query
1382 // "sere", the placeholder "Search posts", and status=paid carried
1383 // as a hidden field.
1384 search := shellPiece(t, "TestClearingASearchIsALinkAndTheNativeCrossIsGone", "search")
1385
1386 mux := http.NewServeMux()
1387 stylesheets(t, mux)
1388 page := func(dir string) string {
1389 return `<!doctype html><html lang="en" dir="` + dir + `"><head><meta charset="utf-8">` +
1390 `<title>search</title><link rel="stylesheet" href="/tokens.css">` +
1391 `<link rel="stylesheet" href="/theme.css"></head><body>` +
1392 `<div rst-page><div rst-list>` + search + `</div></div></body></html>`
1393 }
1394 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
1395 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1396 fmt.Fprint(w, page("ltr"))
1397 })
1398 mux.HandleFunc("GET /rtl", func(w http.ResponseWriter, r *http.Request) {
1399 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1400 fmt.Fprint(w, page("rtl"))
1401 })
1402 // The control: the same input with NONE of this library's CSS, so
1403 // the browser draws its own ✕ exactly as it did before the fix.
1404 // One per direction, because the button is not in the same place in
1405 // both and a control in the wrong direction proves nothing about
1406 // the leg it is controlling.
1407 native := func(dir string) http.HandlerFunc {
1408 return func(w http.ResponseWriter, r *http.Request) {
1409 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1410 fmt.Fprint(w, `<!doctype html><html lang="en" dir="`+dir+`"><head><meta charset="utf-8">`+
1411 `<title>native</title></head><body style="margin:0"><form>`+
1412 `<input id="q" type="search" name="q" value="sere" `+
1413 `style="position:absolute;inset-block-start:0;inset-inline-start:0;inline-size:300px;block-size:40px;font-size:20px">`+
1414 `</form></body></html>`)
1415 }
1416 }
1417 mux.HandleFunc("GET /native", native("ltr"))
1418 mux.HandleFunc("GET /native-rtl", native("rtl"))
1419
1420 rig := harness.New(t, func(string) http.Handler { return mux })
1421 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
1422 defer cancel()
1423
1424 // clickTheNativeCross clicks where the browser draws its cancel
1425 // button: just inside the field's INLINE-END edge, on its centre
1426 // line. Inline-end, not right: Chromium mirrors the button with the
1427 // direction, so in an RTL document it sits at the left. The first
1428 // version of this drive clicked r.right - 8 in both legs, which in
1429 // RTL is the edge the button is never at — an assertion that could
1430 // not fail, on a branch that has shipped several.
1431 //
1432 // The field is focused first because Chromium only makes the button
1433 // live once the field has focus. Worth recording: a drive that
1434 // skips the focus measures a button that is there but inert, and
1435 // concludes it is absent.
1436 clickTheNativeCross := func(sel string) chromedp.Action {
1437 return chromedp.ActionFunc(func(ctx context.Context) error {
1438 var at []float64
1439 expr := fmt.Sprintf(`(() => {
1440 const el = document.querySelector(%q);
1441 const r = el.getBoundingClientRect();
1442 const rtl = getComputedStyle(el).direction === "rtl";
1443 return [rtl ? r.left + 8 : r.right - 8, r.top + r.height / 2];
1444 })()`, sel)
1445 if err := chromedp.Evaluate(expr, &at).Do(ctx); err != nil {
1446 return err
1447 }
1448 return chromedp.MouseClickXY(at[0], at[1]).Do(ctx)
1449 })
1450 }
1451
1452 for _, leg := range []struct{ name, path, control string }{
1453 {"ltr", "/", "/native"},
1454 {"rtl", "/rtl", "/native-rtl"},
1455 } {
1456 // THE CONTROL, per leg and per direction: the browser's own ✕,
1457 // clicked in the same place, must empty a bare input. If it
1458 // does not, Chromium is not drawing the button where this leg
1459 // clicks and the leg's own assertion could not have failed.
1460 var nativeValue string
1461 if err := chromedp.Run(ctx,
1462 chromedp.EmulateViewport(900, 600),
1463 chromedp.Navigate(rig.Origin+leg.control),
1464 chromedp.WaitVisible(`#q`, chromedp.ByQuery),
1465 chromedp.Focus(`#q`, chromedp.ByQuery),
1466 clickTheNativeCross(`#q`),
1467 chromedp.Evaluate(`document.getElementById('q').value`, &nativeValue),
1468 ); err != nil {
1469 t.Fatalf("%s: driving the control input: %v", leg.name, err)
1470 }
1471 if nativeValue != "" {
1472 t.Fatalf("%s: the control input still holds %q after a click on its inline-end edge: this browser is not drawing ::-webkit-search-cancel-button there, so this leg cannot tell a suppressed one from an absent one", leg.name, nativeValue)
1473 }
1474
1475 var raw, stillThere string
1476 if err := chromedp.Run(ctx,
1477 chromedp.EmulateViewport(900, 600),
1478 chromedp.Navigate(rig.Origin+leg.path),
1479 chromedp.WaitVisible(`[rst-search-clear]`, chromedp.ByQuery),
1480 chromedp.Evaluate(clearMeasure, &raw),
1481 chromedp.Focus(`[rst-search] input[type=search]`, chromedp.ByQuery),
1482 clickTheNativeCross(`[rst-search] input[type=search]`),
1483 chromedp.Evaluate(`document.querySelector('[rst-search] input[type=search]').value`, &stillThere),
1484 ); err != nil {
1485 t.Fatalf("%s: driving the search form: %v", leg.name, err)
1486 }
1487 var got clearReading
1488 if err := json.Unmarshal([]byte(raw), &got); err != nil {
1489 t.Fatalf("%s: reading the measurement (%q): %v", leg.name, raw, err)
1490 }
1491 t.Logf("%s: clear target %.2f×%.2fpx to %s, hit %s; field [%.0f..%.0f], clear [%.0f..%.0f]",
1492 leg.name, got.W, got.H, got.Href, got.Hit, got.FormLeft, got.FormRight, got.ClearLeft, got.ClearRight)
1493
1494 if want := "/posts?status=paid"; got.Href != want {
1495 t.Errorf("%s: the clear link goes to %q, want %q — clearing a search must drop q and keep the filter", leg.name, got.Href, want)
1496 }
1497 // The native ✕ is gone: the same click that emptied the control
1498 // input must leave this one alone.
1499 if stillThere != "sere" {
1500 t.Errorf("%s: clicking the field's trailing edge changed its value to %q — the native cancel button is still there beside the link, and it still lies", leg.name, stillThere)
1501 }
1502 // WCAG 2.2 SC 2.5.8, AA. A 17px chip on this branch already
1503 // failed this once.
1504 if got.W < 24 || got.H < 24 {
1505 t.Errorf("%s: the clear target is %.2f×%.2fpx, under WCAG 2.2 SC 2.5.8's 24×24", leg.name, got.W, got.H)
1506 }
1507 if !got.HitIsTheClear {
1508 t.Errorf("%s: the middle of the clear target hits %s, not the link — the box is the right size and something is over it", leg.name, got.Hit)
1509 }
1510 // It has to sit inside the field it belongs to, in both
1511 // directions, or the mirrored layout has put it somewhere a
1512 // reader will not look for it.
1513 if got.ClearLeft < got.FormLeft || got.ClearRight > got.FormRight {
1514 t.Errorf("%s: the clear target spans [%.0f..%.0f] and the field box is [%.0f..%.0f] — it has escaped the field", leg.name, got.ClearLeft, got.ClearRight, got.FormLeft, got.FormRight)
1515 }
1516 }
1517}
1518
1519// frameReading is one preview-sized iframe with a menu open inside it.
1520type frameReading struct {
1521 FrameHeight int
1522 DVH int
1523 MaxBlockSize string
1524 ShortClient int
1525 ShortScroll int
1526 LongClient int
1527 LongScroll int
1528}
1529
1530// TestAMenuOpenedInsideAShortFrameIsStillUsable is the bug the first
1531// version of the cap shipped, and it is worth stating plainly because
1532// it was the fix causing it: `max-block-size: 100dvh - 6rem` has no
1533// floor, and inside an iframe `dvh` is THE FRAME.
1534//
1535// In the design gallery's own 100px preview frames the cap therefore
1536// computed to 4px, and the bulk-bar sample's Actions menu rendered as a
1537// 12px sliver of its 103px of entries. Below a 96px frame the cap
1538// computed to zero and the menu was invisible. A fix aimed at menu
1539// entries nobody could reach had shipped a menu with no entries at all,
1540// and nothing caught it because no gate anywhere opened a menu inside a
1541// frame. This one does.
1542//
1543// It is not a gallery test. Any app embedding a rastrillo screen in a
1544// short iframe had the same bug, which is why the floor is in
1545// tokens.css and the drive is here.
1546//
1547// The dvh control is the load-bearing part. Every assertion below is
1548// about what happens when the viewport is the frame, so the drive first
1549// reads a 100dvh box inside the frame and requires it to measure the
1550// frame's own height. If a future engine changed that, this drive would
1551// otherwise keep passing while testing nothing.
1552func TestAMenuOpenedInsideAShortFrameIsStillUsable(t *testing.T) {
1553 // The two menus as rastrillo rendered them through the dropdown partial:
1554 // short has three entries, long the twelve transliterated locales.
1555 menus := map[string]string{
1556 "short": shellPiece(t, "TestAMenuOpenedInsideAShortFrameIsStillUsable", "short"),
1557 "long": shellPiece(t, "TestAMenuOpenedInsideAShortFrameIsStillUsable", "long"),
1558 }
1559
1560 mux := http.NewServeMux()
1561 stylesheets(t, mux)
1562 // The framed document: the same shape the gallery frames, a sample
1563 // with its menu already open, plus a 100dvh ruler for the control.
1564 mux.HandleFunc("GET /frame", func(w http.ResponseWriter, r *http.Request) {
1565 var b strings.Builder
1566 b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
1567 `<title>frame</title><link rel="stylesheet" href="/tokens.css">` +
1568 `<link rel="stylesheet" href="/theme.css"></head><body style="margin:0">` +
1569 `<div id="dvh" style="block-size:100dvh;inline-size:1px"></div>`)
1570 for _, id := range []string{"short", "long"} {
1571 b.WriteString(`<div id="` + id + `" style="position:absolute;inset-block-start:0;inset-inline-start:8px">`)
1572 b.WriteString(menus[id])
1573 b.WriteString(`</div>`)
1574 }
1575 b.WriteString(`</body></html>`)
1576 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1577 fmt.Fprint(w, b.String())
1578 })
1579 // 100px and 190px are two of the gallery's own previewHeights; 80px
1580 // is under the 96px at which the unfloored cap reached zero, which
1581 // is the case that has to be covered rather than avoided.
1582 heights := []int{80, 100, 190, 260}
1583 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
1584 var b strings.Builder
1585 b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
1586 `<title>frames</title></head><body style="margin:0">`)
1587 for _, h := range heights {
1588 b.WriteString(fmt.Sprintf(
1589 `<iframe data-h="%d" src="/frame" style="display:block;width:420px;height:%dpx;border:0"></iframe>`, h, h))
1590 }
1591 b.WriteString(`</body></html>`)
1592 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1593 fmt.Fprint(w, b.String())
1594 })
1595
1596 rig := harness.New(t, func(string) http.Handler { return mux })
1597 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
1598 defer cancel()
1599
1600 const measure = `(() => {
1601 const out = [];
1602 for (const f of document.querySelectorAll("iframe[data-h]")) {
1603 const d = f.contentDocument;
1604 const open = id => {
1605 const details = d.querySelector("#" + id + " details");
1606 details.open = true;
1607 return details.querySelector("[rst-dropdown-menu]");
1608 };
1609 const s = open("short"), l = open("long");
1610 out.push({
1611 FrameHeight: parseInt(f.dataset.h, 10),
1612 DVH: Math.round(d.getElementById("dvh").getBoundingClientRect().height),
1613 MaxBlockSize: getComputedStyle(l).maxBlockSize,
1614 ShortClient: s.clientHeight, ShortScroll: s.scrollHeight,
1615 LongClient: l.clientHeight, LongScroll: l.scrollHeight,
1616 });
1617 }
1618 return JSON.stringify(out);
1619})()`
1620
1621 var raw string
1622 if err := chromedp.Run(ctx,
1623 chromedp.EmulateViewport(900, 1200),
1624 chromedp.Navigate(rig.Origin+"/"),
1625 chromedp.WaitVisible(`iframe[data-h]`, chromedp.ByQuery),
1626 chromedp.Evaluate(measure, &raw),
1627 ); err != nil {
1628 t.Fatalf("driving the framed menus: %v", err)
1629 }
1630 var got []frameReading
1631 if err := json.Unmarshal([]byte(raw), &got); err != nil {
1632 t.Fatalf("reading the measurement (%q): %v", raw, err)
1633 }
1634 if len(got) != len(heights) {
1635 t.Fatalf("measured %d frames, want %d", len(got), len(heights))
1636 }
1637
1638 for _, f := range got {
1639 t.Logf("a %dpx frame: 100dvh is %dpx, max-block-size %s; three items show %d of %dpx, twelve show %d of %dpx",
1640 f.FrameHeight, f.DVH, f.MaxBlockSize, f.ShortClient, f.ShortScroll, f.LongClient, f.LongScroll)
1641
1642 // THE CONTROL. Everything below is a claim about dvh inside a
1643 // frame, so prove dvh is the frame first.
1644 if f.DVH != f.FrameHeight {
1645 t.Fatalf("a %dpx frame reports 100dvh as %dpx. dvh is no longer the frame's own height, so this drive is not exercising the bug it was written for.", f.FrameHeight, f.DVH)
1646 }
1647
1648 // A three-item menu is 103px and the floor is 8rem, so it must
1649 // fit whole — in an 80px frame as much as a 260px one. This is
1650 // the assertion that was 12-of-103 before the floor. These frames
1651 // are 420px wide, inside the touch query, where the rows are
1652 // 44px taps and the floor is three of them: the same claim.
1653 if f.ShortClient < f.ShortScroll {
1654 t.Errorf("in a %dpx frame a three-item menu shows %d of its %dpx: the cap has collapsed it, and a reader opening this menu sees a sliver", f.FrameHeight, f.ShortClient, f.ShortScroll)
1655 }
1656 // A twelve-item menu cannot fit a short frame and is not meant
1657 // to — it scrolls. What it must not do is disappear.
1658 if f.LongClient < 96 {
1659 t.Errorf("in a %dpx frame a twelve-item menu shows %dpx of its %dpx. An overflowing menu can be scrolled to; a 4px one cannot be used and a 0px one cannot be seen.", f.FrameHeight, f.LongClient, f.LongScroll)
1660 }
1661 }
1662}
1663
1664// orphanReading is one menu in one scroller, read before and after the
1665// scroll that takes its button away.
1666type orphanReading struct {
1667 SummaryTop float64
1668 SummaryBottom float64
1669 ScrollerTop float64
1670 ScrollerBottom float64
1671 MenuTop float64
1672 MenuBottom float64
1673 Painted bool
1674 Hit string
1675 Visibility string
1676 Supports bool
1677 SupportsBogus bool
1678}
1679
1680// TestAMenuDoesNotOutliveTheAnchorScrolledAwayFromUnderIt is the price
1681// of the fixed positioning the flip needs, watched.
1682//
1683// A fixed panel is deliberately outside every scrolling ancestor's clip
1684// — that is how a menu opened inside a list card escapes the card — and
1685// Chromium keeps it tracking its anchor through both window and
1686// inner-scroller scrolls. But the ANCHOR is still inside the clip, so
1687// scrolling a rail's nav until the menu's own button is gone could
1688// leave the menu painted over unrelated content with nothing under it.
1689// The sidebar rail with its thirty-odd links is the live case.
1690//
1691// WHAT THIS DRIVE ACTUALLY GATES, stated plainly because the mutation
1692// run says something a comment could easily be written to hide.
1693// tokens.css writes `position-visibility: anchors-visible` on the
1694// panel, and DELETING THAT LINE DOES NOT FAIL THIS TEST. Chromium's
1695// initial value for the property is already anchors-visible — measured,
1696// on body and on a plain fixed box as well as on an anchored one — so
1697// the behaviour below is the engine's default and the declaration is a
1698// no-op today.
1699//
1700// The declaration stays anyway, and the reason is not superstition: the
1701// CSS spec's initial value is `always`, not anchors-visible. Chromium is
1702// the one diverging, in the safe direction, and an engine that aligned
1703// with the spec tomorrow would orphan every menu in the library with no
1704// other warning. The line is what makes the behaviour ours rather than
1705// borrowed, and the computed-value assertion below is what notices if
1706// the line and the default ever both go. Forcing the property to
1707// `always` — the spec-aligned engine, simulated — fails this drive on
1708// both the computed value and the geometry.
1709//
1710// So this is a WATCH on the engine rather than a proof of our CSS, and
1711// it is worth having as one: it is the only thing anywhere that would
1712// catch the orphaned menu coming back.
1713//
1714// The always-visible control is what stops it passing by mistake. The
1715// second scroller's panel overrides the property to `always`, so it
1716// MUST still be painted after the same scroll. If the drive's way of
1717// asking "is this painted?" ever starts answering no to everything — a
1718// changed API, a mis-selected element, a scroll that moved the wrong
1719// box — the control fails and says so, instead of the subject passing
1720// for the wrong reason.
1721func TestAMenuDoesNotOutliveTheAnchorScrolledAwayFromUnderIt(t *testing.T) {
1722 // Hand-written markup, the way tokens.css says these idioms are
1723 // used, because the second scroller needs an override the partial
1724 // has no key for.
1725 scroller := func(id, panelStyle string) string {
1726 return `<div id="` + id + `" style="block-size:220px;inline-size:320px;overflow-y:auto;` +
1727 `border:1px solid #888;margin:24px">` +
1728 `<details rst-dropdown id="` + id + `-menu" name="rst-menu-` + id + `">` +
1729 `<summary>Menu</summary>` +
1730 `<div rst-dropdown-menu style="` + panelStyle + `">` +
1731 `<a href="#">One</a><a href="#">Two</a><a href="#">Three</a>` +
1732 `</div></details>` +
1733 `<div style="block-size:1200px">tall</div></div>`
1734 }
1735
1736 mux := http.NewServeMux()
1737 stylesheets(t, mux)
1738 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
1739 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1740 fmt.Fprint(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">`+
1741 `<title>orphan</title><link rel="stylesheet" href="/tokens.css">`+
1742 // The 200px of head room is the whole fixture. The scrollers
1743 // have to sit far enough down the page that an anchor
1744 // scrolled ABOVE its scroller's top edge is still at a
1745 // coordinate inside the window — that is the orphan case. A
1746 // scroller at the top of the page takes its anchor off the
1747 // window as well, which hides the bug rather than showing
1748 // it, and was this drive's first draft.
1749 `<link rel="stylesheet" href="/theme.css"></head>`+
1750 `<body style="margin:0;padding-block-start:200px">`+
1751 scroller("subject", "")+
1752 scroller("control", "position-visibility: always")+
1753 `</body></html>`)
1754 })
1755
1756 rig := harness.New(t, func(string) http.Handler { return mux })
1757 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
1758 defer cancel()
1759
1760 // elementFromPoint is the measurement, and that is not a style
1761 // choice. position-visibility hides the box at USED-value time: the
1762 // element keeps its computed `visibility: visible`, keeps its
1763 // layout rectangle, and answers true to
1764 // checkVisibility({visibilityProperty: true}) — all three were
1765 // tried, and all three said "visible" of a menu Chromium was no
1766 // longer painting. What actually changes is paint and hit testing,
1767 // so the question has to be asked of the compositor: is the menu
1768 // the thing at the middle of the menu?
1769 //
1770 // This is the same technique, and the same reason, as
1771 // TestAMenuOpenedInsideAListCardEscapesTheCard above.
1772 read := func(id string) string {
1773 return `(() => {
1774 const box = document.getElementById(` + "`" + id + "`" + `);
1775 const d = document.getElementById(` + "`" + id + `-menu` + "`" + `);
1776 const s = d.querySelector("summary").getBoundingClientRect();
1777 const m = d.querySelector("[rst-dropdown-menu]");
1778 const r = m.getBoundingClientRect();
1779 const b = box.getBoundingClientRect();
1780 const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
1781 return JSON.stringify({
1782 SummaryTop: s.top, SummaryBottom: s.bottom,
1783 ScrollerTop: b.top, ScrollerBottom: b.bottom,
1784 MenuTop: r.top, MenuBottom: r.bottom,
1785 Painted: !!(hit && (hit === m || m.contains(hit))),
1786 Hit: hit ? hit.tagName + "." + (hit.getAttribute("class") || "") : "nothing",
1787 Visibility: getComputedStyle(m).positionVisibility,
1788 Supports: CSS.supports("position-visibility: anchors-visible"),
1789 SupportsBogus: CSS.supports("position-visibility: rastrillo-no-such-value"),
1790 });
1791})()`
1792 }
1793 get := func(id string) orphanReading {
1794 t.Helper()
1795 var raw string
1796 if err := chromedp.Run(ctx, chromedp.Evaluate(read(id), &raw)); err != nil {
1797 t.Fatalf("reading %s: %v", id, err)
1798 }
1799 var out orphanReading
1800 if err := json.Unmarshal([]byte(raw), &out); err != nil {
1801 t.Fatalf("reading %s (%q): %v", id, raw, err)
1802 }
1803 return out
1804 }
1805
1806 if err := chromedp.Run(ctx,
1807 chromedp.EmulateViewport(900, 700),
1808 chromedp.Navigate(rig.Origin+"/"),
1809 chromedp.WaitVisible(`#subject-menu`, chromedp.ByQuery),
1810 chromedp.Click(`#subject-menu > summary`, chromedp.ByQuery),
1811 chromedp.Click(`#control-menu > summary`, chromedp.ByQuery),
1812 chromedp.WaitVisible(`#subject-menu [rst-dropdown-menu]`, chromedp.ByQuery),
1813 ); err != nil {
1814 t.Fatalf("opening the menus: %v", err)
1815 }
1816
1817 before := get("subject")
1818 t.Logf("before the scroll: summary %.0f..%.0f in a scroller %.0f..%.0f; menu %.0f..%.0f, painted=%v (hit %s, position-visibility %s)",
1819 before.SummaryTop, before.SummaryBottom, before.ScrollerTop, before.ScrollerBottom,
1820 before.MenuTop, before.MenuBottom, before.Painted, before.Hit, before.Visibility)
1821
1822 if !before.Supports {
1823 t.Fatalf("this engine does not support position-visibility: anchors-visible. tokens.css writes it; if it has been renamed, every menu below is orphaned again and nothing else would say so.")
1824 }
1825 if before.SupportsBogus {
1826 t.Fatal("CSS.supports agreed to a position-visibility value that does not exist; the support check above is worthless")
1827 }
1828 // The used value, whether it came from tokens.css or from the
1829 // engine's own initial value. This is the assertion that goes red if
1830 // Chromium ever aligns with the spec (whose initial value is
1831 // `always`) AND our own declaration has been lost along the way.
1832 if before.Visibility != "anchors-visible" {
1833 t.Errorf("the menu resolves position-visibility: %s, want anchors-visible — with `always` a menu outlives the button that opened it", before.Visibility)
1834 }
1835 if !before.Painted {
1836 t.Fatalf("the menu is not painted before anything has scrolled — the middle of it hits %s. This drive is measuring the wrong element.", before.Hit)
1837 }
1838
1839 // Take the anchor away.
1840 if err := chromedp.Run(ctx, chromedp.Evaluate(
1841 `(() => { for (const id of ["subject", "control"]) document.getElementById(id).scrollTop = 150; return "ok"; })()`,
1842 new(string))); err != nil {
1843 t.Fatalf("scrolling the anchors away: %v", err)
1844 }
1845 // Anchor positioning — placement and position-visibility both —
1846 // settles during paint, not during the scroll, so the reading waits
1847 // for two frames rather than racing the compositor.
1848 if err := chromedp.Run(ctx, chromedp.Evaluate(
1849 `new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r("ok"))))`,
1850 new(string), func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })); err != nil {
1851 t.Fatalf("waiting for the anchored positions to settle: %v", err)
1852 }
1853
1854 after, control := get("subject"), get("control")
1855 t.Logf("after the scroll: subject summary %.0f..%.0f, scroller %.0f..%.0f, menu %.0f..%.0f painted=%v (hit %s); control menu %.0f..%.0f painted=%v (hit %s)",
1856 after.SummaryTop, after.SummaryBottom, after.ScrollerTop, after.ScrollerBottom,
1857 after.MenuTop, after.MenuBottom, after.Painted, after.Hit,
1858 control.MenuTop, control.MenuBottom, control.Painted, control.Hit)
1859
1860 // THE FIXTURE'S OWN PROOF, in two halves. The anchor really did
1861 // leave the scroller that clips it — and it is still at a
1862 // coordinate inside the window, which is what makes this the
1863 // orphan case rather than a menu that simply scrolled off the top
1864 // of the page with everything else.
1865 if after.SummaryBottom > after.ScrollerTop {
1866 t.Fatalf("the summary's foot is at %.0f and the scroller's head is at %.0f — the anchor has not been scrolled out of the box that clips it, so nothing below is being tested", after.SummaryBottom, after.ScrollerTop)
1867 }
1868 if after.SummaryTop < 0 {
1869 t.Fatalf("the summary is at %.0f, off the top of the WINDOW rather than merely out of its scroller. A menu that follows its anchor off the page is not orphaned, so this fixture would pass with the declaration deleted.", after.SummaryTop)
1870 }
1871 // THE CONTROL: the same scroll, the declaration overridden back.
1872 if !control.Painted {
1873 t.Fatalf("the control menu — position-visibility: always — is also gone after the same scroll; the middle of it hits %s. Whatever this drive is measuring, it is not the declaration under test.", control.Hit)
1874 }
1875 // THE SUBJECT.
1876 if after.Painted {
1877 t.Errorf("the menu is still painted at %.0f..%.0f with its button scrolled out of the box that clips it — it is floating over unrelated content with nothing under it", after.MenuTop, after.MenuBottom)
1878 }
1879}
1880