//go:build browser // Browser drives for the things a Go test cannot see, because they are // geometry a real engine computes: whether a menu opened inside a card // is actually painted outside it, whether a child that owns its own // corners keeps them inside one, and where the sidebar rail puts the // person and which way its language menu opens. // // Run with the rest of the family: // // go test -tags browser ./check/ // // There is no script under test in this file at all: every page here // is static markup plus tokens.css and a theme, and the only // interaction is a click on a
summary, which the browser // handles synchronously. The pages are the ones rastrillo's drives // rendered at the seed commit, read through fixture // (testdata/fixtures/), and the two routes every page links come // from stylesheets (rig_browser_test.go). package check import ( "context" "encoding/json" "fmt" "net/http" "strings" "testing" "time" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" ) // shellPiece is one seeded piece of this file's pages: the partial a // drive rendered at the seed commit, by the drive's name and the piece's. func shellPiece(t *testing.T, test, piece string) string { t.Helper() return fixture(t, "shell_browser_test.go", test, piece) } // clipReading is what the clipping drive measures. Overhang and // HitIsInMenu are the two halves of one claim: the menu really does // hang below the card (or the hit test proves nothing), and the part // that hangs below is really painted (or the card is still clipping). type clipReading struct { Overhang int HitIsInMenu bool Hit string FirstCorner string LastCorner string CardOverflow string } // TestAMenuOpenedInsideAListCardEscapesTheCard is the live-page bug // Paul found: a bulk bar's Actions menu opened inside a [rst-list] was // sliced off at the card's edge. // // The cause was [rst-list]'s overflow: hidden, which was there to clip // the rows' corners to the card's radius and, in doing so, made the // card a clipping context for every absolutely positioned panel inside // it. // // getBoundingClientRect is NOT the measurement: an ancestor's overflow // does not change a descendant's layout rect, so the menu's rect hangs // below the card whether or not it is visible. What clipping does // change is hit testing, so the assertion is elementFromPoint at a // spot below the card's own bottom edge: clipped, nothing of the menu // is there; unclipped, the menu is. // // The second half is the fix's own risk. Dropping the overflow must // not leave the first and last rows square inside a rounded card, so // the corner radii are read back off the engine too. func TestAMenuOpenedInsideAListCardEscapesTheCard(t *testing.T) { // The bulk bar as rastrillo rendered it: three selected, an Actions menu // of Export, Unpublish and a dangerous Delete. bulkBar := shellPiece(t, "TestAMenuOpenedInsideAListCardEscapesTheCard", "bulk-bar") mux := http.NewServeMux() stylesheets(t, mux) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { var body strings.Builder body.WriteString(`` + `list card` + `` + `
` + `
`) body.WriteString(bulkBar) body.WriteString(`
`) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, body.String()) }) rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() var raw string if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-list] [rst-bulkbar]`, chromedp.ByQuery), // The menu is a native
: one real click on the // summary, no script anywhere. chromedp.Click(`[rst-list] [rst-bulkbar] details > summary`, chromedp.ByQuery), chromedp.WaitVisible(`[rst-list] [rst-dropdown-menu]`, chromedp.ByQuery), chromedp.Evaluate(`(() => { const card = document.querySelector("[rst-list]"); const menu = document.querySelector("[rst-list] [rst-dropdown-menu]"); const c = card.getBoundingClientRect(); const m = menu.getBoundingClientRect(); // A point inside the menu and below the card's own bottom // edge — the part a clipping card throws away. const x = Math.round(m.left + m.width / 2); const y = Math.round(c.bottom + Math.max(2, (m.bottom - c.bottom) / 2)); const hit = document.elementFromPoint(x, y); const first = card.firstElementChild, last = card.lastElementChild; return JSON.stringify({ Overhang: Math.round(m.bottom - c.bottom), HitIsInMenu: !!(hit && menu.contains(hit)), Hit: hit ? (hit.tagName.toLowerCase() + "." + (hit.className || "")) : "nothing", FirstCorner: getComputedStyle(first).borderStartStartRadius, LastCorner: getComputedStyle(last).borderEndEndRadius, CardOverflow: getComputedStyle(card).overflowY }); })()`, &raw), ); err != nil { t.Fatalf("driving the list card: %v", err) } var got clipReading if err := json.Unmarshal([]byte(raw), &got); err != nil { t.Fatalf("reading the measurement (%q): %v", raw, err) } // Sanity first: without an overhang the hit test below proves // nothing at all. if got.Overhang <= 4 { t.Fatalf("the open menu hangs only %dpx below the card; this drive cannot tell clipped from contained", got.Overhang) } if !got.HitIsInMenu { 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)", got.Overhang, got.Hit, got.CardOverflow) } // And the card still draws itself as a card: the rows it holds are // rounded to its corners rather than squared off at them. for _, corner := range []struct{ name, value string }{ {"the first row's start corner", got.FirstCorner}, {"the last row's end corner", got.LastCorner}, } { if corner.value == "" || corner.value == "0px" { t.Errorf("%s is %q — dropping the card's overflow left its rows square inside a rounded border", corner.name, corner.value) } } } // railReading is where the sidebar shell's rail puts things, in the // window Paul was looking at. type railReading struct { RailHeight int FootGap int MenuLift int // The frame the first version of this drive did not have. FootGap // answers "is the person at the foot of the rail", which was true // on the day the rail overflowed the window by its own padding — // the rail was 932px in a 900px viewport, the number was in this // test's own passing output, and the person was at the foot of a box // whose last 32px were under the fold. Viewport and the two // overhangs are what turn a claim about the rail into a claim about // the window it has to fit. Viewport int RailOverhang int PersonOverhang int // Whether the rail overflows, and — a different question that was // once measured as if it were the same one — whether it SCROLLS. // // RailScroll is scrollHeight - clientHeight: how much content does // not fit. That is a fine reading and a useless assertion, because // overflow: visible reports it too — with overflow-y removed and a // twenty-link rail it still read 371px. So it is the PREMISE now: // it says the fixture is big enough for this leg to mean anything. // // The claim is the three below it. RailOverflowY is what the engine // computed. RailScrolled is where scrollTop actually landed after // the box was asked to go to its own bottom, which a non-scrollable // box clamps to 0. PersonOverhangScrolled is the promise itself: // after that scroll, is the thing below the fold on screen. RailScroll int RailOverflowY string RailScrolled int PersonOverhangScrolled int LocaleBefore bool // The phone index's two: how far the foot sits from the top of the // rail, and where the open card ends. FootFromTop int MenuBottom int } // railMeasure is the one reading every leg of the rail drive takes, so // a viewport added below cannot quietly measure less than the ones // above it — which is how the frame went missing the first time. const railMeasure = `(() => { const rail = document.querySelector("[rst-shell-rail]"); const person = document.querySelector("[rst-shell-profile] > summary"); const loc = document.querySelector("#rail-locale"); const sum = person; const menu = document.querySelector("[rst-shell-profile] > [rst-dropdown-menu]"); // Both scrollers wound back to the top first. chromedp.Click scrolls // its target into view, and the locale summary sits at the foot of // the rail — so on a rail that overflows, opening the language menu // has already scrolled something, and every "before" reading below // would be a reading of an already-scrolled box. Which one it // scrolled depends on the very property under test: the rail, if the // rail is a scroll container, and otherwise the whole page. Winding // both back is what makes the two cases comparable. window.scrollTo(0, 0); rail.scrollTop = 0; const r = rail.getBoundingClientRect(); const p = person.getBoundingClientRect(); const s = sum.getBoundingClientRect(); const m = menu.getBoundingClientRect(); // Every rect above is taken BEFORE the scroll probe below, because // scrolling the rail moves everything inside it and FootGap is a // reading of where the person sits in an unscrolled rail. const overflowY = getComputedStyle(rail).overflowY; const railScroll = rail.scrollHeight - rail.clientHeight; rail.scrollTop = rail.scrollHeight; const scrolled = Math.round(rail.scrollTop); const pAfter = person.getBoundingClientRect(); const personAfter = Math.round(pAfter.bottom - window.innerHeight); rail.scrollTop = 0; return JSON.stringify({ RailHeight: Math.round(r.height), FootGap: Math.round(r.bottom - p.bottom), MenuLift: Math.round(s.top - m.bottom), Viewport: window.innerHeight, RailOverhang: Math.round(r.bottom - window.innerHeight), RailScroll: railScroll, RailOverflowY: overflowY, RailScrolled: scrolled, PersonOverhang: Math.round(p.bottom - window.innerHeight), PersonOverhangScrolled: personAfter, LocaleBefore: !!(loc.compareDocumentPosition(document.querySelector("#acct-profile")) & Node.DOCUMENT_POSITION_FOLLOWING), FootFromTop: Math.round(document.querySelector("[rst-shell-rail-foot]").getBoundingClientRect().top - r.top), MenuBottom: Math.round(m.bottom) }); })()` // TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward // is the other live-page bug: the sidebar shell left the person block // mid-rail, directly under the nav, with acres of empty rail below it. // // Two claims, both measured rather than asserted about the CSS text: // // 1. The person, now the profile menu's avatar, sits at the rail's // foot — the distance from its bottom to the bottom of the rail is // small. Before the fix it was most of a 900px window. // 2. The profile menu is a DROPUP. Its card's bottom edge is at or // above the avatar's top edge; a dropdown's would be below it. // // The 390 leg measures the phone index, where the rail is the whole // page and the same menu sits at the top beside the heading: the foot // is at the top of the rail and the open card stays on screen. func TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward(t *testing.T) { // The sidebar shell as rastrillo's drive rendered it: a two-link nav, // Grace Hopper as the person, one account link (#acct-profile) and // the language menu (#rail-locale, English and Gaeilge). Three // pages: the content page, the index (the view written as index), // and the tall page below. const drive = "TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward" html := shellPiece(t, drive, "page") indexHTML := shellPiece(t, drive, "index") // The same shell with a rail that does not fit a short window, for // the third leg below. A separate page rather than a taller nav on // the one above, because the first two legs assert numbers that a // twenty-link rail would move — "the person is at the foot" and // "the collapsed rail is not stretched" are claims about a rail // with room to spare, and they are the claims worth keeping. // // Twenty links is not decoration: the design system's own rail // carries about thirty, and the leg below exists because a rail // that overflows is the case a two-link fixture can never reach. // The tall page's twenty-link nav (Posts, Drafts, Comments, Media // and on to Settings) is what makes it overflow at 420px, and the // leg FAILS if it stops doing so. tallHTML := shellPiece(t, drive, "tall") mux := http.NewServeMux() stylesheets(t, mux) mux.HandleFunc("GET /index", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, indexHTML) }) mux.HandleFunc("GET /tall", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, tallHTML) }) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, html) }) rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() var raw string if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery), chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery), chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery), chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery), chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery), chromedp.Evaluate(railMeasure, &raw), ); err != nil { t.Fatalf("driving the sidebar rail: %v", err) } var got railReading if err := json.Unmarshal([]byte(raw), &got); err != nil { t.Fatalf("reading the measurement (%q): %v", raw, err) } // Logged, not just asserted: the number that gave the regression // away was already in this test's passing output — "a 932px rail" at // a 900px viewport — and nobody read it, because nothing in the // output said what it was supposed to be compared to. It says so now. t.Logf("wide: rail %dpx in a %dpx viewport (rail overhang %dpx, person overhang %dpx), person %dpx above the rail's foot", got.RailHeight, got.Viewport, got.RailOverhang, got.PersonOverhang, got.FootGap) // Sanity: the rail is the full-height column this claim is about. if got.RailHeight < 600 { 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) } // THE FRAME. Everything below asks where things sit inside the rail; // this asks whether the rail sits inside the window, which is the // question the first version of this drive never put and the one the // regression it missed was an answer to. // // block-size: 100dvh is a promise about the viewport, so the box // that has to keep it is the BORDER box: with the rail as a content // box its padding was added on top, the border box came to // 100dvh + 2×var(--rst-sp-4), and a rail sticky at // inset-block-start: 0 hung its last 32px under the foot of the // window. overflow-y: auto is no defence — the content fits the // content box, so no scrollbar is ever owed — and no assertion about // where the person sits INSIDE the rail can see it. if got.RailHeight > got.Viewport { 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", got.RailHeight, got.Viewport, got.RailHeight-got.Viewport) } if got.RailOverhang > 0 { t.Errorf("the sticky rail's bottom edge is %dpx below the foot of a %dpx window", got.RailOverhang, got.Viewport) } // And the symptom, measured where a reader met it: the person block // at the rail's foot, clipped by the window rather than by the rail. if got.PersonOverhang > 0 { 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) } // A rail's padding is var(--rst-sp-4); anything inside a couple of // steps of the bottom edge is at the foot, anything further is not. if got.FootGap > 48 { 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) } if !got.LocaleBefore { 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") } if got.MenuLift < 0 { 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) } // And the phone index, which replaced the drawer. The rail IS the // page there, at least the height of the window, and the profile // menu is lifted to its top, beside the heading: at the foot of a // long index it would be out of reach. So the foot's distance from // the rail's top is the measurement, and the card, language open, // is asserted to be on screen. var narrow string if err := chromedp.Run(ctx, chromedp.EmulateViewport(390, 780), chromedp.Navigate(rig.Origin+"/index"), chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery), chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery), chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery), chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery), chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery), chromedp.Evaluate(railMeasure, &narrow), ); err != nil { t.Fatalf("driving the phone index: %v", err) } var small railReading if err := json.Unmarshal([]byte(narrow), &small); err != nil { t.Fatalf("reading the index measurement (%q): %v", narrow, err) } t.Logf("index: rail %dpx in a %dpx viewport, foot %dpx from the rail's top", small.RailHeight, small.Viewport, small.FootFromTop) if small.RailHeight < small.Viewport-1 { t.Errorf("the phone index's rail is %dpx in a %dpx window; the rail is the page there", small.RailHeight, small.Viewport) } if small.FootFromTop < 0 || small.FootFromTop > 32 { 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) } if small.MenuBottom > small.Viewport { t.Errorf("the profile card on the index ends %dpx below the window", small.MenuBottom-small.Viewport) } // And a SHORT window, which is the other half of "fits the // viewport" and the half a desktop reviewer never sees. 1280×420 is // a laptop with the devtools open, or a phone in landscape: wide // enough for the full-height rail, too short for its content. // // The claim here is deliberately weaker than the one above, because // the honest one is weaker: the rail must still fit the window, and // whatever does not fit INSIDE the rail must be reachable by // scrolling it. A person below the fold of a rail that scrolls is // fine; a person below the fold of a rail that does not is the bug // this test is named after, one viewport smaller. // // Two things this leg used to get wrong, and both made it a gate // that gated nothing — removing overflow-y: auto from the rail // passed it green: // // - it ran on the two-link fixture, which fits 420px with room // to spare, so the overflow case it was written for never // arose. It now runs on /tall, and it FAILS if that page stops // overflowing: a leg whose premise has quietly become false is // worse than no leg, because it reads as coverage. // - it measured scrollHeight - clientHeight, which is overflow, // not scrollability: overflow: visible reports it too. It now // asks the engine what it computed, asks the box to scroll to // its own bottom, and asks whether the person came into view. var shortRaw string if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 420), chromedp.Navigate(rig.Origin+"/tall"), chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery), chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery), chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery), chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery), chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery), chromedp.Evaluate(railMeasure, &shortRaw), ); err != nil { t.Fatalf("driving the rail in a short window: %v", err) } var short railReading if err := json.Unmarshal([]byte(shortRaw), &short); err != nil { t.Fatalf("reading the short-window measurement (%q): %v", shortRaw, err) } 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", short.RailHeight, short.Viewport, short.RailOverhang, short.RailScroll, short.RailOverflowY, short.PersonOverhang, short.PersonOverhangScrolled, short.RailScrolled) if short.RailHeight > short.Viewport { 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) } if short.RailOverhang > 0 { t.Errorf("the sticky rail hangs %dpx below the foot of a %dpx window", short.RailOverhang, short.Viewport) } // The premise, asserted before anything that depends on it, and in // terms of the FIXTURE rather than of the property under test: if // the rail's content fits, every assertion below is vacuously true // and this leg is decoration, which is exactly what it was. if short.RailScroll <= 0 { 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) } if short.PersonOverhang <= 0 { 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) } // It overflows, so it has to scroll. Three readings, because the // one that used to be here could not tell a scrollable box from a // leaking one. if short.RailOverflowY != "auto" && short.RailOverflowY != "scroll" { 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) } if short.RailScrolled <= 0 { 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) } if short.PersonOverhangScrolled > 0 { 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) } } // barReading is one measurement of the topbar shell's bar: what the // collapse control and the tail are doing, how the tail's three blocks // are laid out, and whether the bar spills sideways. type barReading struct { MenuShown bool // the disclosure's summary is drawn MenuOpen bool // ...and its open attribute is set TailShown bool // nav/account/locale are on the page at all Rows int // distinct block-start positions among the three: 1 = inline, 3 = stacked AccountEndPx int // gap from the end of the account, language and scheme menus to the bar's AccountMenu bool // the account dropdown's panel is drawn Overflow int // how far the document scrolls past its own client width MainTop int // main's block-start edge: the page under the bar } // TestTheTopbarCollapsesItsTailBehindOneDisclosure is §9: the topbar // shell had no narrow layout at all. [rst-shell-bar] is a wrapping flex // row and [rst-shell-account] has margin-inline-start: auto, so // narrowing the window did not collapse anything — it wrapped the // account and locale menus onto a second row and shoved them to the // trailing edge. Nothing was broken; nothing had been written. // // Three claims, in the order they matter: // // 1. Wide, nothing changed. Above 800px the disclosure is not drawn and // nav, account and locale sit inline on the bar's own row with the // account at the inline end, which is the layout that shipped before // the collapse existed. // 2. Narrow, one control. Below 800px the tail is not drawn until the // disclosure is opened, and opened it is a card over the page: three // stacked rows that push nothing down. // 3. THE TRAP. The account menu is
. Opening // it must not close the navigation it was opened from.
exclusivity is document-wide rather than sibling-scoped, so // this is not bought by keeping the two elements out of each other's // subtree — the disclosure has to be in a different group, and this // is the assertion that says so out loud. // // There is no script on this page: the shell's