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 console shell — the fourth page frame, and the
4// only one with two pieces of chrome to fold rather than one.
5//
6// Run with the rest of the family:
7//
8// go test -tags browser ./check/
9//
10// Every drive in this file carries a CONTROL: a second page, served by
11// the same mux, measured by the same JavaScript, in the same browser,
12// in the same run — differing from the page under test by the few
13// declarations that ARE the thing being gated. The control has to come
14// out the other way. Design spec §7-v2: a drive that finds nothing must
15// be demonstrated finding something, or its zero cannot be told apart
16// from a broken instrument. This project has shipped twelve gates that
17// gated nothing, and the most recent survived independent reproduction
18// by the simple expedient of nobody asking it a question whose answer
19// was already known.
20//
21// There is no script under test here. The pages are markup, tokens.css
22// and a theme; the shell's own <script defer> tags resolve to addresses
23// this mux does not serve, exactly as the other shell drives leave
24// them. Every state change below is a click on a <summary>. The pages
25// are the ones rastrillo's drives rendered at the seed commit, read through
26// fixture (testdata/fixtures/).
27package check
28
29import (
30 "context"
31 "encoding/json"
32 "fmt"
33 "net/http"
34 "slices"
35 "strings"
36 "testing"
37 "time"
38
39 "github.com/chromedp/chromedp"
40
41 "amadan.net/rastrillo/fiesta"
42 "amadan.net/rastrillo/fiesta/internal/harness"
43)
44
45// consoleReading is one measurement of a console page. Everything here
46// is read off getBoundingClientRect and window.innerHeight — geometry
47// the engine computed, never a claim about the text of a rule.
48type consoleReading struct {
49 // The chrome, and how much of it a reader can see.
50 MenuShown bool
51 MenuOpen bool
52 TailShown bool
53 RailShown bool
54 VisibleSummaries int
55 AccountMenu bool
56 BackShown bool
57
58 // The wide frame.
59 TailEndPx int
60 BarAboveRail bool
61 RailBeforeMain bool
62
63 // The viewport question. NavHeight is the border box, because
64 // max-block-size is a promise about the window and the window
65 // measures border boxes — that is the whole of the regression this
66 // gate exists for.
67 Viewport int
68 NavTop int
69 NavBottom int
70 NavHeight int
71 NavOverhang int
72 NavScrolls int
73
74 Overflow int
75 Dir string
76
77 // The legacy-engine readings. RailBottom against MainBottom is the
78 // discriminator between "the rail is in its declared grid area"
79 // (which spans the page row AND the footer row) and "the rail was
80 // auto-placed into row 2" (which stops where main stops). They look
81 // identical in a screenshot.
82 RailBottom int
83 MainBottom int
84 RailBorderEnd string
85 NavLinksShown int
86}
87
88// consoleMeasure is the one instrument. Both drives use it, and so does
89// each drive's control, which is what makes the control a control: a
90// different reading of the same measurement rather than a different
91// measurement.
92const consoleMeasure = `(() => {
93 const shown = el => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
94 const de = document.documentElement;
95 const bar = document.querySelector("[rst-shell-bar]");
96 const menu = document.querySelector("[rst-shell-menu]");
97 const rail = document.querySelector("[rst-shell-rail]");
98 const nav = document.querySelector("[rst-shell-nav]");
99 const account = document.querySelector("[rst-shell-account]");
100 const locale = document.querySelector("#bar-locale");
101 const main = document.querySelector("[rst-shell-main]");
102 const panel = account.querySelector("[rst-dropdown-menu]");
103 const br = bar.getBoundingClientRect();
104 const rr = rail.getBoundingClientRect();
105 const nr = nav.getBoundingClientRect();
106 const mr = main.getBoundingClientRect();
107 // The inline-end gap of the LAST thing on the bar, whichever of the
108 // two menus that is: the current layout puts the language first and
109 // the account last, a layout from before the profile the other way
110 // round. margin-inline-start: auto pushes the pair to the end, and
111 // measuring the one that is not last would read the other's width
112 // and call it a layout.
113 const lr = locale.getBoundingClientRect(), ar = account.getBoundingClientRect();
114 const ltr = getComputedStyle(de).direction !== "rtl";
115 const end = ltr ? Math.max(lr.right, ar.right) : Math.min(lr.left, ar.left);
116 // Every <summary> a reader can see anywhere in the document. The
117 // claim "one control" is a count, not an assertion about one element:
118 // a second disclosure added beside this one would satisfy every
119 // other reading here and fail only this.
120 let summaries = 0;
121 document.querySelectorAll("summary").forEach(s => { if (shown(s)) summaries++; });
122 return JSON.stringify({
123 MenuShown: shown(menu.querySelector("summary")),
124 MenuOpen: menu.open,
125 TailShown: shown(account) || shown(locale),
126 RailShown: shown(rail),
127 VisibleSummaries: summaries,
128 AccountMenu: shown(panel),
129 BackShown: shown(document.querySelector("[rst-shell-back] a")),
130 TailEndPx: Math.round(ltr ? br.right - end : end - br.left),
131 BarAboveRail: Math.round(br.bottom) <= Math.round(rr.top) + 1,
132 RailBeforeMain: ltr ? Math.round(rr.right) <= Math.round(mr.left) + 1
133 : Math.round(rr.left) >= Math.round(mr.right) - 1,
134 Viewport: window.innerHeight,
135 NavTop: Math.round(nr.top),
136 NavBottom: Math.round(nr.bottom),
137 NavHeight: Math.round(nr.height),
138 NavOverhang: Math.round(nr.bottom - window.innerHeight),
139 NavScrolls: nav.scrollHeight - nav.clientHeight,
140 Overflow: de.scrollWidth - de.clientWidth,
141 Dir: getComputedStyle(de).direction,
142 RailBottom: Math.round(rr.bottom),
143 MainBottom: Math.round(mr.bottom),
144 RailBorderEnd: getComputedStyle(rail).borderInlineEndWidth,
145 NavLinksShown: [...nav.querySelectorAll("a")].filter(shown).length
146 });
147})()`
148
149// consoleControlCSS is the control's whole difference from the page
150// under test, and each half re-creates a bug this project has actually
151// shipped rather than one imagined for the occasion.
152//
153// The first half defeats the rule that hides the rail on a narrow
154// content page — the failure "the tail folded and the rail did not",
155// which is a phone page with its whole navigation stacked above the
156// content it was opened for.
157//
158// The second half is the rail regression itself, verbatim: a box sized
159// against the viewport that also carries padding, as a CONTENT box, so
160// its border box is 32px taller than the window it promised to fit.
161// The gate that missed it asked where the person sat inside the rail
162// and got a true answer about a rail hanging off the screen.
163const consoleControlCSS = `
164@media (max-width: 799px) {
165 [rst-shell-console] > [rst-shell-rail] { display: flex !important; }
166}
167@media (min-width: 800px) {
168 [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] {
169 block-size: 100dvh !important;
170 box-sizing: content-box !important;
171 max-block-size: none !important;
172 padding: 1rem !important;
173 }
174}`
175
176// consolePage is the console shell as rastrillo's drive rendered it, by
177// case, and its control twin. Every case has two account links, the
178// language menu as #bar-locale (English and Gaeilge) and a foot; the
179// cases differ in their nav, direction and view: "ltr" is Posts (the
180// current page), Drafts and Settings; "ltr-index" the same with the
181// view written as index; "rtl" is Posts and Drafts, mirrored; "tall"
182// is the twenty-link nav that cannot fit a 420px window.
183func consolePage(t *testing.T, kase string) (page, control string) {
184 t.Helper()
185 return consolePageFrom(t, fixture(t, "console_shell_browser_test.go", "consolePage", kase))
186}
187
188// legacyConsolePage is consolePage's "ltr" case through the console
189// layout as it was before the phone index, whose rail is gated on the
190// Menu's [open].
191func legacyConsolePage(t *testing.T) (page, control string) {
192 t.Helper()
193 return consolePageFrom(t, fixture(t, "console_shell_browser_test.go", "legacyConsolePage", "ltr"))
194}
195
196// consolePageFrom is a page and its control twin: the page with
197// consoleControlCSS in a <style> before </head>.
198func consolePageFrom(t *testing.T, page string) (_, control string) {
199 t.Helper()
200 const head = "</head>"
201 i := strings.Index(page, head)
202 if i < 0 {
203 t.Fatal("the console shell rendered no </head> to inject the control's stylesheet into")
204 }
205 control = page[:i] + "<style>" + consoleControlCSS + "</style>" + page[i:]
206 return page, control
207}
208
209// consoleServe puts the page at / and its control twin at /control.
210func consoleServe(t *testing.T, page, control string) *harness.Rig {
211 t.Helper()
212 mux := http.NewServeMux()
213 stylesheets(t, mux)
214 serve := func(body string) http.HandlerFunc {
215 return func(w http.ResponseWriter, r *http.Request) {
216 w.Header().Set("Content-Type", "text/html; charset=utf-8")
217 fmt.Fprint(w, body)
218 }
219 }
220 mux.HandleFunc("GET /control", serve(control))
221 mux.HandleFunc("GET /", serve(page))
222 return harness.New(t, func(string) http.Handler { return mux })
223}
224
225func readConsole(t *testing.T, raw string) consoleReading {
226 t.Helper()
227 var got consoleReading
228 if err := json.Unmarshal([]byte(raw), &got); err != nil {
229 t.Fatalf("reading the measurement (%q): %v", raw, err)
230 }
231 return got
232}
233
234// TestTheConsoleFoldsItsBarAndIndexesItsRail is the design work of the
235// fourth shell, measured.
236//
237// Every other shell has ONE thing to put away below 800px. This one has
238// two — the bar's tail and the navigation rail — and each has its own
239// pattern: the tail folds behind one <details rst-shell-menu> and opens
240// as a card, and the rail is an index page, shown on the page whose
241// view block says index and replaced by a back control everywhere else.
242//
243// The claims, in the order they matter:
244//
245// 1. Wide, both chromes are drawn and neither is behind anything: bar
246// across the top, rail beside the page, account at the bar's inline
247// end. This is the layout the shell exists for.
248// 2. Narrow and closed, ONE control for the bar. Not "a control" —
249// exactly one visible summary in the whole document (the back
250// control is a link), which is the assertion a second disclosure
251// would fail and nothing else here would.
252// 3. One click reveals the tail card and NOT the rail: the rail
253// follows the page's view, never the Menu's [open]. On the index
254// view the rail is shown with the Menu closed.
255// 4. THE TRAP. The account menu is <details name="rst-menus">.
256// <details name> exclusivity is document-wide rather than
257// sibling-scoped, so a disclosure in that group would be closed by
258// the menu it just revealed, and the card with it.
259// 5. Nothing is reordered at any width, so the reader's eye and the
260// tab key agree: bar, then rail, at 320px and at 1280px, in both
261// directions of the language.
262//
263// THE CONTROL, and what it is for: /control is this page with the
264// rail's hide rule defeated. Leg 2's reading of RailShown must be false
265// on the page and TRUE on the control. A drive whose RailShown is
266// hard-wired false — a stale selector, a rail that never rendered,
267// getBoundingClientRect on a detached node — passes leg 2 and fails
268// here, which is the whole point.
269func TestTheConsoleFoldsItsBarAndIndexesItsRail(t *testing.T) {
270 page, control := consolePage(t, "ltr")
271 rig := consoleServe(t, page, control)
272 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
273 defer cancel()
274
275 at := func(t *testing.T, w, h int, path string, before ...chromedp.Action) consoleReading {
276 t.Helper()
277 acts := []chromedp.Action{
278 chromedp.EmulateViewport(int64(w), int64(h)),
279 chromedp.Navigate(rig.Origin + path),
280 chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery),
281 }
282 acts = append(acts, before...)
283 var raw string
284 acts = append(acts, chromedp.Evaluate(consoleMeasure, &raw))
285 if err := chromedp.Run(ctx, acts...); err != nil {
286 t.Fatalf("driving %s at %dx%d: %v", path, w, h, err)
287 }
288 return readConsole(t, raw)
289 }
290
291 // 1. Wide: both chromes, no control, and the frame in the right
292 // shape.
293 wide := at(t, 1280, 900, "/")
294 t.Logf("wide 1280x900: menu=%v tail=%v rail=%v summaries=%d barAboveRail=%v railBeforeMain=%v tailEnd=%dpx",
295 wide.MenuShown, wide.TailShown, wide.RailShown, wide.VisibleSummaries, wide.BarAboveRail, wide.RailBeforeMain, wide.TailEndPx)
296 if wide.MenuShown {
297 t.Error("the collapse control is drawn at 1280px; above 800px both chromes are laid out and it has nothing to do")
298 }
299 if !wide.TailShown || !wide.RailShown {
300 t.Fatalf("at 1280px tail=%v rail=%v: the console is not showing both chromes, which is the only reason this shell exists", wide.TailShown, wide.RailShown)
301 }
302 if !wide.BarAboveRail {
303 t.Error("the bar is not above the rail at 1280px; a console is a bar ACROSS the top with the rail beneath it")
304 }
305 if !wide.RailBeforeMain {
306 t.Error("the rail is not on the inline-start side of the page column at 1280px")
307 }
308 if wide.TailEndPx > 40 {
309 t.Errorf("the bar's tail ends %dpx short of the bar's inline end; margin-inline-start: auto is no longer reaching it (the bar's own inline padding is 16px)", wide.TailEndPx)
310 }
311 if wide.Overflow > 1 {
312 t.Errorf("the wide console spills %dpx sideways at 1280px", wide.Overflow)
313 }
314
315 // 1b. And at exactly 800px, the breakpoint itself: a min-width query
316 // matches AT its own width, so 800 is wide and 799 is narrow. An
317 // off-by-one here is a window width with neither layout.
318 edge := at(t, 800, 780, "/")
319 if edge.MenuShown || !edge.TailShown || !edge.RailShown {
320 t.Errorf("at exactly 800px control=%v tail=%v rail=%v; a min-width query matches at its own width, so 800px is the wide layout",
321 edge.MenuShown, edge.TailShown, edge.RailShown)
322 }
323
324 // 2. Narrow and closed: one control, and both chromes behind it.
325 closed := at(t, 390, 780, "/")
326 t.Logf("narrow 390x780 closed: menu=%v tail=%v rail=%v visible summaries=%d",
327 closed.MenuShown, closed.TailShown, closed.RailShown, closed.VisibleSummaries)
328 if !closed.MenuShown {
329 t.Fatal("there is no collapse control at 390px: the console has no narrow layout")
330 }
331 if closed.TailShown {
332 t.Error("the bar's account and language menus are still drawn at 390px with the disclosure closed")
333 }
334 if closed.RailShown {
335 t.Error("the navigation rail is still drawn at 390px with the disclosure closed; the tail folded and the rail did not")
336 }
337 if closed.VisibleSummaries != 1 {
338 t.Errorf("a reader at 390px meets %d disclosures, want exactly 1. Two chromes fold behind ONE control here; a second summary is a second thing to learn and is the answer this shell was designed against",
339 closed.VisibleSummaries)
340 }
341 if closed.Overflow > 1 {
342 t.Errorf("the collapsed console spills %dpx sideways at 390px", closed.Overflow)
343 }
344
345 // 2b. THE CONTROL for leg 2. Same instrument, same viewport, same
346 // closed disclosure — one stylesheet apart. RailShown must come
347 // out the other way, or the false above proves nothing.
348 ctrl := at(t, 390, 780, "/control")
349 if !ctrl.RailShown {
350 t.Errorf("CONTROL FAILED: the control page defeats the rule that hides the rail while the disclosure is closed, and this drive still reports the rail hidden (rail=%v, summaries=%d). "+
351 "The measurement is not reading the rail, so leg 2's pass says nothing about the shell", ctrl.RailShown, ctrl.VisibleSummaries)
352 }
353 if !ctrl.MenuShown || ctrl.MenuOpen {
354 t.Errorf("CONTROL FAILED: the control is meant to differ from the page only in the rail's hide rule, and its disclosure reads shown=%v open=%v", ctrl.MenuShown, ctrl.MenuOpen)
355 }
356
357 // 3. One click reveals the tail card, and not the rail: on a
358 // content page the rail is the index's, behind the back control.
359 open := at(t, 390, 780, "/",
360 chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery),
361 chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-account] > summary`, chromedp.ByQuery),
362 )
363 if !open.TailShown {
364 t.Error("opening the Menu did not reveal the bar's tail")
365 }
366 if open.RailShown {
367 t.Error("opening the Menu revealed the rail on a content page; the rail follows the page's view, and the legacy :has() gate is matching new markup")
368 }
369 if open.Overflow > 1 {
370 t.Errorf("the opened console spills %dpx sideways at 390px", open.Overflow)
371 }
372
373 // 4. The trap: the account menu must not close the disclosure it
374 // sits behind, and opening it must not bring the rail in.
375 var trapRaw string
376 if err := chromedp.Run(ctx,
377 // Settle rather than WaitVisible: the failure this step exists
378 // for is the account menu never appearing, because it closed the
379 // disclosure it lives behind. Waiting would report that as a
380 // timeout, and a timeout says nothing about <details name>.
381 chromedp.Click(`[rst-shell-account] > summary`, chromedp.ByQuery),
382 chromedp.Sleep(250*time.Millisecond),
383 chromedp.Evaluate(consoleMeasure, &trapRaw),
384 ); err != nil {
385 t.Fatalf("opening the account menu inside the collapsed console: %v", err)
386 }
387 trap := readConsole(t, trapRaw)
388 if !trap.MenuOpen {
389 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")
390 }
391 if !trap.TailShown {
392 t.Error("the bar's tail vanished when the account menu opened")
393 }
394 if trap.RailShown {
395 t.Error("the rail appeared when the account menu opened; on a content page it is the index's, never the Menu's")
396 }
397 if !trap.AccountMenu {
398 t.Error("the account menu's panel is not drawn inside the collapsed console")
399 }
400
401 // 3b. The index view at 390: the rail is the page, with the Menu
402 // closed, and still one visible summary. 5b below is the same
403 // page at 320, which keeps the rail's reflow covered now that a
404 // content page no longer shows it.
405 indexHTML, indexControl := consolePage(t, "ltr-index")
406 indexRig := consoleServe(t, indexHTML, indexControl)
407 indexCtx, indexCancel := context.WithTimeout(indexRig.Context(), 60*time.Second)
408 defer indexCancel()
409 atIndex := func(w, h int) consoleReading {
410 t.Helper()
411 var raw string
412 if err := chromedp.Run(indexCtx,
413 chromedp.EmulateViewport(int64(w), int64(h)),
414 chromedp.Navigate(indexRig.Origin+"/"),
415 chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery),
416 chromedp.Evaluate(consoleMeasure, &raw),
417 ); err != nil {
418 t.Fatalf("driving the console's index at %dx%d: %v", w, h, err)
419 }
420 return readConsole(t, raw)
421 }
422 idx := atIndex(390, 780)
423 if !idx.RailShown || idx.MenuOpen || idx.VisibleSummaries != 1 {
424 t.Errorf("the console's index at 390: rail=%v menu open=%v summaries=%d; want the rail shown with the Menu closed and one visible summary", idx.RailShown, idx.MenuOpen, idx.VisibleSummaries)
425 }
426
427 // 5. The smallest viewport the shells promise, with the card open on
428 // a content page: the tail, the back control, no rail.
429 tiny := at(t, 320, 640, "/",
430 chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery),
431 chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-account] > summary`, chromedp.ByQuery),
432 )
433 if tiny.Overflow > 1 {
434 t.Errorf("the opened console spills %dpx sideways at 320px", tiny.Overflow)
435 }
436 if !tiny.TailShown || !tiny.BackShown || tiny.RailShown {
437 t.Errorf("at 320px, card open: tail=%v back=%v rail=%v; want the tail and the back control, no rail", tiny.TailShown, tiny.BackShown, tiny.RailShown)
438 }
439
440 // 5b. And the index at 320: the rail, reflowed.
441 tinyIndex := atIndex(320, 640)
442 if !tinyIndex.RailShown || tinyIndex.Overflow > 1 {
443 t.Errorf("the console's index at 320: rail=%v, spills %dpx sideways", tinyIndex.RailShown, tinyIndex.Overflow)
444 }
445
446 // 6. And the mirror. Every rule in this shell is a logical property
447 // and the wide frame is a named grid area, so RTL should need no
448 // second rule — which is a claim, and claims get measured.
449 rtlPage, rtlControl := consolePage(t, "rtl")
450 rtlRig := consoleServe(t, rtlPage, rtlControl)
451 rtlCtx, rtlCancel := context.WithTimeout(rtlRig.Context(), 60*time.Second)
452 defer rtlCancel()
453 var rtlRaw string
454 if err := chromedp.Run(rtlCtx,
455 chromedp.EmulateViewport(1280, 900),
456 chromedp.Navigate(rtlRig.Origin+"/"),
457 chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery),
458 chromedp.Evaluate(consoleMeasure, &rtlRaw),
459 ); err != nil {
460 t.Fatalf("driving the mirrored console: %v", err)
461 }
462 rtl := readConsole(t, rtlRaw)
463 if rtl.Dir != "rtl" {
464 t.Fatalf("the mirrored page reports direction %q; the fixture is not mirrored and legs below prove nothing", rtl.Dir)
465 }
466 if !rtl.RailBeforeMain {
467 t.Error("in RTL the rail is not on the inline-start (right) side of the page column: the grid areas are not following the writing mode")
468 }
469 if rtl.TailEndPx > 40 {
470 t.Errorf("in RTL the bar's tail ends %dpx short of the bar's inline end; the auto margin is not following the writing mode", rtl.TailEndPx)
471 }
472 if rtl.Overflow > 1 {
473 t.Errorf("the mirrored console spills %dpx sideways at 1280px", rtl.Overflow)
474 }
475}
476
477// TestTheConsoleRailFitsTheViewport is the gate this project earned the
478// hard way, asked of the fourth shell before it ships rather than after.
479//
480// The regression: a rail with `padding` and `block-size: 100dvh` under
481// content-box has a BORDER box 32px taller than the window it was sized
482// against. Sticky at the top, its last 32px hung under the foot of the
483// screen at every scroll position, with the person block in them.
484// overflow-y: auto was no defence — the content fitted the content box,
485// so no scrollbar was ever owed — and the gate that missed it asserted
486// that the person sat at the foot OF THE RAIL, which stayed perfectly
487// true while the rail hung off the screen.
488//
489// So this drive asks the viewport, not the container:
490//
491// 1. The nav's BORDER box is never taller than the window.
492// 2. Scrolled to the foot of the document, the whole of it — top edge
493// and bottom edge — is inside the window. Nothing in it is
494// unreachable at every scroll position, which is what the
495// regression actually cost.
496// 3. And the fixture is doing its job: the nav is longer than the cap,
497// so the cap is engaged. A "it fits" from a nav with three links in
498// it is a measurement of nothing.
499//
500// THE CONTROL: /control is this page with the regression put back,
501// declaration for declaration. The same three readings must come out
502// the other way there. A drive that reports "fits" because it is
503// measuring a detached node, or the wrong element, or a box that never
504// laid out, passes 1–3 and fails the control.
505func TestTheConsoleRailFitsTheViewport(t *testing.T) {
506 page, control := consolePage(t, "tall")
507 rig := consoleServe(t, page, control)
508 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
509 defer cancel()
510
511 // A deliberately SHORT window, which is where the regression lived:
512 // a viewport-sized box overhangs by its padding whatever the window
513 // height is, but a short window is where a reader meets it and
514 // where twenty links certainly exceed the cap.
515 const w, h = 1280, 420
516
517 measureAt := func(t *testing.T, path string, scroll bool) consoleReading {
518 t.Helper()
519 acts := []chromedp.Action{
520 chromedp.EmulateViewport(w, h),
521 chromedp.Navigate(rig.Origin + path),
522 chromedp.WaitVisible(`[rst-shell-rail] [rst-shell-nav] a`, chromedp.ByQuery),
523 }
524 if scroll {
525 acts = append(acts,
526 chromedp.Evaluate(`window.scrollTo(0, document.documentElement.scrollHeight); "ok"`, new(string)),
527 chromedp.Sleep(250*time.Millisecond))
528 }
529 var raw string
530 acts = append(acts, chromedp.Evaluate(consoleMeasure, &raw))
531 if err := chromedp.Run(ctx, acts...); err != nil {
532 t.Fatalf("driving %s at %dx%d (scrolled=%v): %v", path, w, h, scroll, err)
533 }
534 return readConsole(t, raw)
535 }
536
537 // Logged before anything is asserted, and named against what it is
538 // supposed to be compared to: the number that gave the regression
539 // away was already in a passing test's output — "a 932px rail" at a
540 // 900px viewport — and nobody read it, because nothing said what it
541 // should have been.
542 top := measureAt(t, "/", false)
543 bottom := measureAt(t, "/", true)
544 t.Logf("console rail, %dx%d viewport: nav border box %dpx (viewport %dpx), %dpx of it scrolls inside itself; at scroll top %d..%d, at document foot %d..%d",
545 w, h, top.NavHeight, top.Viewport, top.NavScrolls, top.NavTop, top.NavBottom, bottom.NavTop, bottom.NavBottom)
546
547 // 3 first, because 1 and 2 mean nothing without it.
548 if top.NavScrolls <= 0 {
549 t.Fatalf("the nav is %dpx and scrolls %dpx inside itself: it is SHORTER than the cap, so this drive is measuring a box that was never constrained. The fixture has to overflow or the fit proves nothing",
550 top.NavHeight, top.NavScrolls)
551 }
552 // 1. The border box against the window. This is the regression's own
553 // shape, and it is a claim about the box the window measures.
554 //
555 // Its caveat, recorded where it lives rather than in a report
556 // nobody greps: in THIS shell the padding is on the rail, not on
557 // the nav, so a content-box mutation alone does not inflate the
558 // nav's border box and this leg does not fire on it — leg 2 is
559 // what catches that one, by 16px. The leg is kept for three
560 // reasons: it fires the day somebody gives the nav padding of its
561 // own, which is the exact edit that shipped the regression the
562 // first time; it is the reading the control below pins to the
563 // pixel (452 = 420 + 32), which is what proves the height
564 // measurement is live rather than inert; and its message names
565 // the fix, which leg 2's cannot.
566 if top.NavHeight > top.Viewport {
567 t.Errorf("the rail's nav is %dpx in a %dpx window: %dpx taller than the viewport it is sized against, so whatever is at its foot is under the fold at every scroll position. box-sizing: border-box on the capped box is the fix",
568 top.NavHeight, top.Viewport, top.NavHeight-top.Viewport)
569 }
570 // 2. And reachability, which is what the reader actually lost.
571 if bottom.NavTop < 0 || bottom.NavBottom > bottom.Viewport {
572 t.Errorf("scrolled to the foot of the document the rail's nav occupies %d..%dpx of a %dpx window: part of it is off-screen with nothing left to scroll to reach it",
573 bottom.NavTop, bottom.NavBottom, bottom.Viewport)
574 }
575
576 // THE CONTROL. Same drive, same instrument, same window — the
577 // regression put back in a <style> block. All three readings must
578 // invert. If they do not, this test's zero is a broken harness and
579 // not a shell that fits.
580 ctop := measureAt(t, "/control", false)
581 cbottom := measureAt(t, "/control", true)
582 t.Logf("control (the regression restored): nav border box %dpx (viewport %dpx), scrolls %dpx; at scroll top %d..%d, at document foot %d..%d",
583 ctop.NavHeight, ctop.Viewport, ctop.NavScrolls, ctop.NavTop, ctop.NavBottom, cbottom.NavTop, cbottom.NavBottom)
584
585 if ctop.NavHeight <= ctop.Viewport {
586 t.Errorf("CONTROL FAILED: the control page sizes the nav at 100dvh as a content box with 1rem of padding, so its border box must be %dpx taller than the %dpx window — and this drive measured %dpx. The height reading is not measuring a border box against the viewport, so the pass above says nothing",
587 32, ctop.Viewport, ctop.NavHeight)
588 }
589 if cbottom.NavTop >= 0 && cbottom.NavBottom <= cbottom.Viewport {
590 t.Errorf("CONTROL FAILED: the control's oversized nav cannot fit a %dpx window at any scroll position, and this drive reports it fitting at %d..%dpx. The reachability reading is inert",
591 cbottom.Viewport, cbottom.NavTop, cbottom.NavBottom)
592 }
593 // And the control differs from the page in exactly the way it says
594 // it does, arithmetically: block-size: 100dvh on a content box with
595 // 1rem of padding is a border box of viewport + 32, to the pixel.
596 // This is the calibration proper — not "the control failed" but
597 // "the control failed BY THE PREDICTED AMOUNT". An instrument that
598 // reports some other number is measuring some other box, and its
599 // agreement with the page above would be luck.
600 if want := ctop.Viewport + 32; ctop.NavHeight != want {
601 t.Errorf("CONTROL FAILED: the control's nav should measure exactly %dpx (a %dpx content box plus 2×1rem of padding) and this drive read %dpx. The height reading is not the border box of the element the rule names",
602 want, ctop.Viewport, ctop.NavHeight)
603 }
604}
605
606// stripHas returns tokens.css as an engine that does not implement
607// :has() would see it.
608//
609// The important part is what it drops, which is more than the selector.
610// A selector list is invalid AS A WHOLE if any selector in it is
611// invalid, so such an engine drops the entire rule — every declaration
612// in it, including the ones that have nothing to do with :has(), and
613// including the ones the OTHER selectors in the list were carrying.
614// That is the finding this drive was written for: the console's wide
615// rail rule used to co-list two plain selectors with two :has() ones,
616// so a legacy engine lost `grid-area: rail` along with the fight it was
617// having about `display`, and the rail landed in the right cell only
618// because grid auto-placement happened to agree with the declared area.
619// It looked correct. It was luck.
620//
621// It reads the stylesheet with cssItems, the declaration reader's own
622// statement splitter, into the grouping rules (@media, @supports,
623// @container) and out again. A pattern over braces ended a rule at a
624// brace inside a string, and so dropped the wrong text or kept a
625// :has() rule.
626func stripHas(t testing.TB, css string) (string, int) {
627 t.Helper()
628 dropped := 0
629 // Comments come out FIRST, with the comment reader the gates use. A
630 // browser tokenises them away before it ever looks at a selector,
631 // and the first version of this function did not: the wide
632 // layout's comment explains why the :has() spelling is repeated
633 // there, so the characters ":has(" sat in the text immediately
634 // before a rule that does not use it and the stripper took the
635 // rule. The wide legs of the drive below caught it, which is the
636 // useful thing to say about them — they are not only a gate on the
637 // stylesheet, they are the control on this function.
638 css = stripCSSComments(t, css)
639 var strip func(src string) string
640 strip = func(src string) string {
641 items, err := cssItems(src)
642 if err != nil {
643 t.Fatal(err)
644 }
645 var b strings.Builder
646 for _, it := range items {
647 prelude := collapseSpace(it.prelude)
648 switch {
649 case !it.hasBlock:
650 b.WriteString(it.prelude + ";")
651 case strings.HasPrefix(prelude, "@") && groupingRules[atRuleName(prelude)]:
652 b.WriteString(it.prelude + "{" + strip(it.block) + "}")
653 case !strings.HasPrefix(prelude, "@") && selectorHas(prelude):
654 dropped++
655 b.WriteString("\n")
656 default:
657 b.WriteString(it.prelude + "{" + it.block + "}")
658 }
659 }
660 return b.String()
661 }
662 return strip(css), dropped
663}
664
665// selectorHas reports whether a selector list uses :has(), read with
666// cssSpan, so :has( inside a quoted attribute value is text.
667func selectorHas(prelude string) bool {
668 for i := 0; i < len(prelude); {
669 end, _, err := cssSpan(prelude, i)
670 if err != nil {
671 return false
672 }
673 if prelude[i] == ':' && strings.HasPrefix(prelude[end:], "has(") {
674 return true
675 }
676 i = end
677 }
678 return false
679}
680
681// TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas is the gate on the
682// sentence the shell, tokens.css and docs/site/templates.md all make:
683// that this shell's dependence on :has() has a CHOSEN failure
684// direction.
685//
686// The claim has two halves, and only one of them was ever true by
687// accident:
688//
689// 1. Narrow, the rail stays VISIBLE. The rules are written as
690// hide-when-closed, so the rule an old engine drops is the one that
691// would have hidden the navigation. The page gets longer; nothing
692// becomes unreachable. Spelled the other way round, the same
693// missing selector would be a phone that cannot navigate.
694// 2. Wide, the frame is UNTOUCHED. Nothing about the console's grid
695// depends on :has() — and that has to be true of the rules as
696// written, not merely of the intent, which is what a co-listed
697// selector quietly took away.
698//
699// Half 2 is measured on the discriminator rather than on appearance:
700// the rail's declared area spans the page row AND the footer row, so
701// its bottom edge is below main's. An auto-placed rail stops where main
702// stops. The two are indistinguishable in a screenshot and this is the
703// only reading that tells them apart.
704//
705// THE CONTROL, twice over. The strip itself is checked to have dropped
706// rules that matter (a strip that silently did nothing would make every
707// reading below a reading of the ordinary page), and the same narrow
708// reading is taken on the REAL stylesheet, where the rail must be
709// hidden. If the rail reads visible on both, the visibility is the
710// fixture's and not the degradation's.
711func TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas(t *testing.T) {
712 real := string(fiesta.TokensCSS())
713 legacy, dropped := stripHas(t, real)
714
715 // Control on the instrument before anything is measured with it.
716 if dropped == 0 {
717 t.Fatal("the :has()-less simulation dropped no rules at all: it is serving the ordinary stylesheet under another name, and every reading below is a reading of the page as it already is")
718 }
719 if strings.Contains(legacy, ":has(") {
720 t.Fatal("the :has()-less simulation left a :has() selector in the stylesheet; it is not the engine it claims to simulate")
721 }
722 // And it reads a brace inside a string as text: a pattern over
723 // braces ended [rst-a]'s rule at the quoted } and kept the :has()
724 // rule.
725 probe, n := stripHas(t, `[rst-a] { content: "}"; } [rst-b]:has(i) { color: red; content: "{"; } [rst-c] { color: blue; }`)
726 if kept := ruleSelectors(t, probe); n != 1 || !slices.Equal(kept, []string{"[rst-a]", "[rst-c]"}) {
727 t.Fatalf("the :has()-less simulation of a stylesheet with quoted braces dropped %d rules and kept %q; want 1 dropped and [rst-a], [rst-c] kept", n, kept)
728 }
729 consoleRules := 0
730 for _, sel := range ruleSelectors(t, legacy) {
731 consoleRules += strings.Count(sel, "rst-shell-console")
732 }
733 if consoleRules == 0 {
734 t.Fatal("the simulation dropped every console rule in the file; the shell has no stylesheet left and the legs below would be measuring an unstyled document")
735 }
736 t.Logf(":has()-less simulation: %d rules dropped, %d console selectors survive", dropped, consoleRules)
737
738 // The legacy layout: the :has() gate now lives only for markup with
739 // no view, and the legs below are about that gate.
740 page, _ := legacyConsolePage(t)
741 newPage, _ := consolePage(t, "ltr")
742 newIndex, _ := consolePage(t, "ltr-index")
743 stripped := func(p string) string { return strings.Replace(p, `href="/tokens.css"`, `href="/legacy-tokens.css"`, 1) }
744
745 mux := http.NewServeMux()
746 mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) {
747 w.Header().Set("Content-Type", "text/css")
748 fmt.Fprint(w, real)
749 })
750 mux.HandleFunc("GET /legacy-tokens.css", func(w http.ResponseWriter, r *http.Request) {
751 w.Header().Set("Content-Type", "text/css")
752 fmt.Fprint(w, legacy)
753 })
754 mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) {
755 css, ok := fiesta.ThemeCSS(fiesta.ThemeNames()[0])
756 if !ok {
757 http.Error(w, "no theme", http.StatusInternalServerError)
758 return
759 }
760 w.Header().Set("Content-Type", "text/css")
761 w.Write(css)
762 })
763 mux.HandleFunc("GET /legacy", func(w http.ResponseWriter, r *http.Request) {
764 w.Header().Set("Content-Type", "text/html; charset=utf-8")
765 fmt.Fprint(w, stripped(page))
766 })
767 mux.HandleFunc("GET /new-page", func(w http.ResponseWriter, r *http.Request) {
768 w.Header().Set("Content-Type", "text/html; charset=utf-8")
769 fmt.Fprint(w, stripped(newPage))
770 })
771 mux.HandleFunc("GET /new-index", func(w http.ResponseWriter, r *http.Request) {
772 w.Header().Set("Content-Type", "text/html; charset=utf-8")
773 fmt.Fprint(w, stripped(newIndex))
774 })
775 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
776 w.Header().Set("Content-Type", "text/html; charset=utf-8")
777 fmt.Fprint(w, page)
778 })
779
780 rig := harness.New(t, func(string) http.Handler { return mux })
781 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
782 defer cancel()
783
784 at := func(t *testing.T, w, h int, path string) consoleReading {
785 t.Helper()
786 var raw string
787 if err := chromedp.Run(ctx,
788 chromedp.EmulateViewport(int64(w), int64(h)),
789 chromedp.Navigate(rig.Origin+path),
790 chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery),
791 chromedp.Evaluate(consoleMeasure, &raw),
792 ); err != nil {
793 t.Fatalf("driving %s at %dx%d: %v", path, w, h, err)
794 }
795 return readConsole(t, raw)
796 }
797
798 // 2. Wide, on the legacy stylesheet: the frame is the declared one.
799 lw := at(t, 1280, 900, "/legacy")
800 t.Logf("legacy engine, 1280x900: rail shown=%v barAbove=%v beforeMain=%v borderInlineEnd=%s rail bottom %dpx vs main bottom %dpx",
801 lw.RailShown, lw.BarAboveRail, lw.RailBeforeMain, lw.RailBorderEnd, lw.RailBottom, lw.MainBottom)
802 if !lw.RailShown || !lw.TailShown {
803 t.Fatalf("without :has() the wide console loses its chrome: rail=%v tail=%v", lw.RailShown, lw.TailShown)
804 }
805 if !lw.BarAboveRail || !lw.RailBeforeMain {
806 t.Errorf("without :has() the wide frame is not bar-over-rail-beside-page: barAbove=%v beforeMain=%v", lw.BarAboveRail, lw.RailBeforeMain)
807 }
808 if lw.RailBorderEnd == "0px" {
809 t.Error("without :has() the rail has no inline-end border: the wide rail rule is being dropped whole, which means it is co-listing plain selectors with :has() ones again")
810 }
811 // THE DISCRIMINATOR.
812 if lw.RailBottom <= lw.MainBottom {
813 t.Errorf("without :has() the rail ends at %dpx and main ends at %dpx: the rail is sitting in the page's row rather than in the area declared for it, which spans the footer row too. "+
814 "That is grid auto-placement agreeing with the declared area by coincidence — the wide rail rule is being dropped whole. Keep the :has() selectors in a rule of their own",
815 lw.RailBottom, lw.MainBottom)
816 }
817
818 // 1. Narrow, on the legacy stylesheet: the rail is visible, whole,
819 // and the page still fits.
820 ln := at(t, 390, 780, "/legacy")
821 t.Logf("legacy engine, 390x780 closed: rail shown=%v with %d/3 links visible, control shown=%v, overflow=%dpx",
822 ln.RailShown, ln.NavLinksShown, ln.MenuShown, ln.Overflow)
823 if !ln.RailShown {
824 t.Error("without :has() the rail is HIDDEN at 390px: the hide rule is the one that survived, so the chosen failure direction is inverted and a reader on an old phone has no navigation at all")
825 }
826 if ln.NavLinksShown != 3 {
827 t.Errorf("without :has() the disclosed-by-default rail shows %d of 3 navigation links at 390px", ln.NavLinksShown)
828 }
829 if ln.Overflow > 1 {
830 t.Errorf("without :has() the console spills %dpx sideways at 390px", ln.Overflow)
831 }
832
833 // THE CONTROL for leg 1: the same reading on the REAL stylesheet,
834 // where the answer is known and is the opposite one.
835 rn := at(t, 390, 780, "/")
836 if rn.RailShown {
837 t.Errorf("CONTROL FAILED: on the real stylesheet the rail must be hidden at 390px with the disclosure closed, and this drive reports it shown. " +
838 "The visible rail on the legacy page is then the fixture's doing and not the degradation's, and leg 1 measures nothing")
839 }
840
841 // 3. New markup needs no :has() at all: on the stripped stylesheet a
842 // content page hides its rail and the index shows it, because the
843 // view decides, not the Menu.
844 np, ni := at(t, 390, 780, "/new-page"), at(t, 390, 780, "/new-index")
845 if np.RailShown || !ni.RailShown {
846 t.Errorf("without :has(), new markup at 390: rail on a content page %v, on the index %v; want hidden then shown", np.RailShown, ni.RailShown)
847 }
848}
849