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// The design-system floors on the examples: every state of every
4// non-layout example, each loaded as its own document, held to the
5// tap-target floor, the 16px text floor, the icon sizes and spacing,
6// a clean load, and the same controls with no script at all. The
7// seeded sizing fixture (sizing_browser_test.go) is the preservation
8// drive for the same floors; this is the coverage drive, which the
9// examples feed. The layouts are held with the shells, not here.
10package check
11
12import (
13 "context"
14 "encoding/json"
15 "fmt"
16 "net/http"
17 "sort"
18 "strings"
19 "sync"
20 "testing"
21 "time"
22
23 "github.com/chromedp/chromedp"
24
25 "amadan.net/rastrillo/fiesta"
26 "amadan.net/rastrillo/fiesta/example"
27 "amadan.net/rastrillo/fiesta/internal/harness"
28)
29
30// floorState is one example state as the floors drive visits it: its
31// address, and the path of its document and of its no-script twin.
32type floorState struct {
33 Name, State string
34 Path, NoScript string
35}
36
37// floorServer serves the states and counts what it served, by path,
38// so the drive can show it visited every state of the manifest rather
39// than every state of its own list.
40type floorServer struct {
41 http.Handler
42 mu sync.Mutex
43 served map[string]int
44}
45
46// snapshot returns how many times each path has been served so far,
47// as a copy, so a leg can subtract the count before it from the count
48// after it.
49func (s *floorServer) snapshot() map[string]int {
50 s.mu.Lock()
51 defer s.mu.Unlock()
52 out := map[string]int{}
53 for k, v := range s.served {
54 out[k] = v
55 }
56 return out
57}
58
59// floorPages reads every state of every non-layout example through
60// the manifest and example.Document, and serves each at the depth the
61// tree keeps it, examples/<name>/<state>.html, so the document's own
62// relative links (../../css/tokens.css, ../../js/<file>,
63// ../assets/<picture>) resolve against the embedded tree beside it
64// and nothing in the document is rewritten. Beside each is its
65// no-script twin, the same document with every script tag stripped,
66// at examples/<name>/<state>.noscript.html, the same depth. The rest
67// of the tree (css/, js/, examples/assets/) is served as it is.
68func floorPages(t *testing.T) ([]floorState, *floorServer) {
69 t.Helper()
70 m, err := example.Load(fiesta.FS())
71 if err != nil {
72 t.Fatal(err)
73 }
74 var states []floorState
75 pages := map[string]string{}
76 for _, e := range m.Examples {
77 if e.Document || e.Kind == "layout" {
78 continue
79 }
80 for _, s := range e.States {
81 doc, err := example.Document(fiesta.FS(), e.Name, s.ID)
82 if err != nil {
83 t.Fatal(err)
84 }
85 if !strings.Contains(doc, "<body") {
86 t.Fatalf("%s/%s is not a whole document", e.Name, s.ID)
87 }
88 st := floorState{Name: e.Name, State: s.ID,
89 Path: "/examples/" + e.Name + "/" + s.ID + ".html",
90 NoScript: "/examples/" + e.Name + "/" + s.ID + ".noscript.html"}
91 pages[st.Path] = doc
92 pages[st.NoScript] = scriptTags.ReplaceAllString(doc, "")
93 if strings.Contains(pages[st.NoScript], "<script") {
94 t.Fatalf("%s/%s still links a script with its tags stripped", e.Name, s.ID)
95 }
96 states = append(states, st)
97 }
98 }
99 if len(states) <= 100 {
100 t.Fatalf("the manifest lists %d non-layout states, want more than 100; a set this small is a broken manifest, not a design system", len(states))
101 }
102 srv := &floorServer{served: map[string]int{}}
103 mux := http.NewServeMux()
104 tree := http.FileServerFS(fiesta.FS())
105 for _, prefix := range []string{"/css/", "/js/", "/examples/assets/"} {
106 mux.Handle("GET "+prefix, tree)
107 }
108 for path, doc := range pages {
109 path, doc := path, doc
110 mux.HandleFunc("GET "+path, func(w http.ResponseWriter, _ *http.Request) {
111 srv.mu.Lock()
112 srv.served[path]++
113 srv.mu.Unlock()
114 w.Header().Set("Content-Type", "text/html; charset=utf-8")
115 fmt.Fprint(w, doc)
116 })
117 }
118 srv.Handler = mux
119 return states, srv
120}
121
122// manifestStates is the manifest's own list of non-layout states, as
123// name/state, read apart from floorPages so the visited check compares
124// what the server served against the manifest rather than against the
125// loop that asked for it.
126func manifestStates(t *testing.T) map[string]floorState {
127 t.Helper()
128 m, err := example.Load(fiesta.FS())
129 if err != nil {
130 t.Fatal(err)
131 }
132 out := map[string]floorState{}
133 for _, e := range m.Examples {
134 if e.Document || e.Kind == "layout" {
135 continue
136 }
137 for _, s := range e.States {
138 out[e.Name+"/"+s.ID] = floorState{Name: e.Name, State: s.ID,
139 Path: "/examples/" + e.Name + "/" + s.ID + ".html",
140 NoScript: "/examples/" + e.Name + "/" + s.ID + ".noscript.html"}
141 }
142 }
143 return out
144}
145
146// floorProbes is the tap inventory as selector lists: every probe the
147// floor holds on a page (the narrow: and modal: prefixes stripped, the
148// elsewhere: entries left out, since a drive of their own measures
149// them in a state no example reaches), plus the stretched links the
150// inventory exempts, which measureFn measures as the row or tile they
151// stretch over (the whole-row target); and the exemptions proper, the
152// inline link and the keyboard-only submit. An example state carries
153// whatever controls its component has, and only the ones the
154// inventory names are the floor's to hold: a link in running prose is
155// not. The lists are what the measurement filters on.
156func floorProbes(wide bool) (probes, exempt string, keyed map[string]string) {
157 var sels, ex []string
158 keyed = map[string]string{}
159 for _, e := range tapInventory {
160 sel := e.Probe
161 switch {
162 case strings.HasPrefix(sel, "elsewhere:"):
163 continue
164 case strings.HasPrefix(sel, "narrow:"):
165 if wide {
166 continue
167 }
168 sel = strings.TrimPrefix(sel, "narrow:")
169 case strings.HasPrefix(sel, "modal:"):
170 sel = strings.TrimPrefix(sel, "modal:")
171 }
172 sels = append(sels, sel)
173 keyed[sel] = e.Idiom
174 }
175 for sel, why := range tapExempt {
176 if strings.Contains(why, "stretched") {
177 sels = append(sels, sel)
178 keyed[sel] = "stretched link, measured as its row or tile"
179 continue
180 }
181 ex = append(ex, sel)
182 }
183 sort.Strings(ex)
184 return strings.Join(sels, ", "), strings.Join(ex, ", "), keyed
185}
186
187// scriptBuilt are the inventory idioms a script draws at run time, so
188// a page with no script cannot carry them: the no-script leg allows
189// their absence and nothing else's.
190var scriptBuilt = map[string]string{
191 "[rst-combo-option]": "select.js builds the combobox's list",
192 "[rst-dtp-pick]": "datetime.js builds the pick button",
193 "[rst-dtp-row]": "datetime.js builds the suggestion list",
194}
195
196// scriptShown are the controls one example's markup carries hidden and
197// a script shows, keyed by example name and inventory selector: with no
198// script a button that could only do nothing stays hidden, on purpose,
199// so the no-script leg allows its absence on that example alone. Keyed
200// that narrowly so a control another example needs a script to show
201// still fails, and each entry must excuse an absence, so one left
202// behind by a renamed example fails too.
203var scriptShown = map[string]string{
204 "snippet [rst-btn]": "copy.js shows the Copy button only where there is a clipboard; with no script it stays hidden on purpose",
205}
206
207// floorGaps are the inventory idioms no example state renders, each
208// with why, which the seeded sizing page holds instead. A new gap
209// fails the drive, and so does a pinned gap that an example has since
210// filled.
211var floorGaps = map[string]string{
212 "[rst-shell-chrome] > summary": "the legacy sidebar drawer: no example carries legacy shell markup",
213}
214
215// floorsJS measures one state as measureEverything measures the sizing
216// page, over the inventory's controls only: the closed page, then every
217// <details> opened alone (the way overlayJS opens it, the shell's
218// sibling tail included), then every combobox's list and every date
219// field's list, each opened by the script that owns it; a list that
220// does not open within ninety frames is reported as failed. It repeats
221// overlayJS's walk and measureEverything's list opening rather than
222// calling them because both are written against the sizing page (the
223// walk returns one overlay per evaluate, the lists are found by
224// #sizing-combo and the one [rst-dtp]) and they take no filter; the
225// filter here is what keeps a prose link out of the floor. It is a
226// function the caller applies to the two selector lists. It also
227// returns which probes are rendered on the page, exemptions aside,
228// with no element measured (an idiom an example renders that the
229// floor never read), how many elements each probe had measured, and
230// the gap between every icon in a control and the word beside it, in
231// both directions of text, with every overlay open.
232const floorsJS = `(async (probes, exempt) => { ` + measureFn + `;
233 const floored = el => el.matches(probes) && !el.matches(exempt);
234 const skip = el => !floored(el);
235 const frame = () => new Promise(r => requestAnimationFrame(r));
236 const frames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
237 const until = async cond => { for (let i = 0; i < 90; i++) { if (cond()) return true; await frame(); } return false; };
238 const name = el => el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") +
239 [...el.attributes].filter(a => a.name.startsWith("rst-")).map(a => "[" + a.name + (a.value ? "=" + a.value : "") + "]").join("") +
240 " “" + (el.getAttribute("aria-label") || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30) + "”";
241 const out = {Closed: measure(document, skip), Overlays: [], Lists: {}, Failed: [], Unmeasured: [], Counts: {}, Gaps: []};
242 for (const d of document.querySelectorAll("details")) {
243 for (let p = d; p; p = p.parentElement && p.parentElement.closest("details")) p.open = true;
244 const tail = d.closest("[rst-shell-tail], .rst-shell__tail");
245 if (tail && tail.previousElementSibling && tail.previousElementSibling.matches("details")) tail.previousElementSibling.open = true;
246 let outer = d;
247 while (outer.parentElement && outer.parentElement.closest("details")) outer = outer.parentElement.closest("details");
248 const os = outer.querySelector(":scope > summary");
249 if (os) os.scrollIntoView({block: "center", inline: "center"});
250 await frames();
251 const summary = d.querySelector(":scope > summary");
252 const got = measure(d, el => el === summary || skip(el));
253 if (d.matches("[rst-shell-menu], [rst-shell-chrome]") && d.nextElementSibling) got.push(...measure(d.nextElementSibling, skip));
254 document.querySelectorAll("details[open]").forEach(x => { x.open = false; });
255 out.Overlays.push(got);
256 }
257 // Each field's own list, not the page's first: a date range holds
258 // two fields, and the first one's list stays closed while the
259 // second's is open.
260 const shown = l => !!l && l.checkVisibility() && l.querySelectorAll("[role=option]").length > 0;
261 for (const combo of document.querySelectorAll("[rst-combo] [role=combobox]")) {
262 const label = "the combobox's list (" + name(combo) + ")", list = combo.closest("[rst-combo]").querySelector("[rst-combo-list]");
263 combo.focus(); combo.click();
264 if (await until(() => shown(list))) out.Lists[label] = measure(list, skip);
265 else out.Failed.push(label);
266 combo.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
267 await frames();
268 }
269 for (const dtp of document.querySelectorAll("[rst-dtp] [role=combobox]")) {
270 const label = "the date field's list (" + name(dtp) + ")", list = dtp.closest("[rst-dtp]").querySelector("[rst-dtp-list]");
271 dtp.focus(); dtp.value = "t"; dtp.dispatchEvent(new Event("input", {bubbles: true}));
272 if (await until(() => shown(list))) out.Lists[label] = measure(list, skip);
273 else out.Failed.push(label);
274 dtp.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
275 await frames();
276 }
277 const rendered = el => { if (el.checkVisibility && !el.checkVisibility({visibilityProperty: true})) return false; const r = el.getBoundingClientRect(); return r.width >= 2 && r.height >= 2 && getComputedStyle(el).clipPath !== "inset(50%)"; };
278 for (const sel of probes.split(", ")) {
279 const all = [...document.querySelectorAll(sel)];
280 out.Counts[sel] = all.filter(e => e.hasAttribute("data-measured")).length;
281 const present = all.filter(e => rendered(e) && !e.matches(exempt) && !e.closest("[inert]"));
282 if (present.length && !present.some(e => e.hasAttribute("data-measured"))) out.Unmeasured.push(sel);
283 }
284 // The space between an icon and the word beside it, read as
285 // TestAnIconKeepsItsSpaceBesideItsText reads it: from the icon's edge
286 // to the word's first letter, with every overlay open so menu items
287 // are drawn, in the page's direction and the other one.
288 const was = document.documentElement.dir;
289 document.querySelectorAll("details").forEach(d => { d.open = true; });
290 for (const dir of ["ltr", "rtl"]) {
291 document.documentElement.dir = dir;
292 await frames();
293 for (const c of document.querySelectorAll(probes)) {
294 if (c.matches(exempt) || !c.checkVisibility()) continue;
295 for (const i of c.querySelectorAll("svg.icon")) {
296 if (!i.checkVisibility()) continue;
297 const ir = i.getBoundingClientRect();
298 for (const [side, n] of [["after", i.nextSibling], ["before", i.previousSibling]]) {
299 if (!n || n.nodeType !== 3 || !n.textContent.trim()) continue;
300 const r = document.createRange();
301 if (side === "after") { r.setStart(n, n.textContent.search(/\S/)); r.setEnd(n, n.textContent.length); }
302 else { r.setStart(n, 0); r.setEnd(n, n.textContent.replace(/\s+$/, "").length); }
303 const tx = r.getBoundingClientRect();
304 if (tx.width === 0) continue;
305 const gap = side === "after" ? (dir === "rtl" ? ir.left - tx.right : tx.left - ir.right) : (dir === "rtl" ? tx.left - ir.right : ir.left - tx.right);
306 out.Gaps.push({Name: name(c), Dir: dir, Side: side, Gap: Math.round(gap * 10) / 10});
307 }
308 }
309 }
310 }
311 document.documentElement.dir = was;
312 document.querySelectorAll("details[open]").forEach(d => { d.open = false; });
313 return JSON.stringify(out);
314})`
315
316// floorReading is what floorsJS returns for one state.
317type floorReading struct {
318 Closed []targetReading
319 Overlays [][]targetReading
320 Lists map[string][]targetReading
321 Failed []string
322 Unmeasured []string
323 Counts map[string]int
324 Gaps []struct {
325 Name, Dir, Side string
326 Gap float64
327 }
328}
329
330// TestEveryExampleStateMeetsTheFloors is the floors on the examples.
331// For every state of every non-layout example, each loaded as its own
332// document at 390px under a coarse pointer and at 1280px with a
333// mouse, and at 1280px with every script tag stripped (the page as a
334// browser with no script sees it):
335//
336// - every interactive element the tap inventory names is at least
337// 44 pixels on the phone and 32 on the desktop, whole in the
338// viewport and hittable, overlays and lists included, a stretched
339// link measured as the row or tile it stretches over;
340// - every text control is at least 16px on the phone;
341// - every icon inside a control is 16px, a caret's 12, and keeps at
342// least 3px between its edge and the word beside it, in both
343// directions of text;
344// - the page loads with no console error, no failed request and no
345// JS value leaked to the screen (the rig's screen gate);
346// - with no script, the same idioms are measured on each state as
347// with scripts, the three idioms a script builds and the one
348// control scriptShown names aside, so a component whose controls
349// need a script to appear fails here.
350//
351// Every inventory idiom a state renders must have been measured on it,
352// so an idiom cannot drop out of the floor because its example moved;
353// the idioms no example renders at all are pinned in floorGaps, so a
354// new gap fails. The drive checks, against the manifest's own list,
355// that the server served every state's page on every leg, and that
356// the manifest lists more than a hundred, so an emptied manifest or a
357// loop that skipped cannot pass by measuring nothing.
358//
359// It is two tests, so the suite's slices can run them on shards of
360// their own: the phone's leg, and the two desktop legs together, since
361// the no-script leg is held to what the scripted one measured. Every
362// leg's checks are as they were. Of the checks over every leg, the
363// pinned gaps are checked in both, each for what it measures, and an
364// idiom no state renders is looked for where the inventory says it is
365// rendered: a narrow: idiom on the phone, every other one at 1280.
366// That last is stricter than the one test, which accepted an idiom
367// seen on the phone alone; and both tests need a caret and an icon.
368func TestEveryExampleStateMeetsTheFloorsOnAPhone(t *testing.T) {
369 meetTheFloors(t, true, []floorLeg{
370 {"390 touch", 390, 844, true, 44, false},
371 })
372}
373
374func TestEveryExampleStateMeetsTheFloorsWithAMouse(t *testing.T) {
375 meetTheFloors(t, false, []floorLeg{
376 {"1280 mouse", 1280, 900, false, 32, false},
377 {"1280 mouse, no scripts", 1280, 900, false, 32, true},
378 })
379}
380
381// floorLeg is one way the floors drive loads every state.
382type floorLeg struct {
383 name string
384 w, h int64
385 coarse bool
386 floor float64
387 noscript bool
388}
389
390// meetTheFloors drives legs, the phone's or the desktop's, over every
391// state.
392func meetTheFloors(t *testing.T, phone bool, legs []floorLeg) {
393 states, srv := floorPages(t)
394 want := manifestStates(t)
395 if len(want) <= 100 {
396 t.Fatalf("the manifest lists %d non-layout states, want more than 100", len(want))
397 }
398 seen := map[string]int{}
399 withScripts := map[string]map[string]bool{}
400 shownUsed := map[string]bool{}
401 noscript := false
402 var carets, icons int
403 for _, leg := range legs {
404 t.Run(leg.name, func(t *testing.T) {
405 var opts []harness.Option
406 if leg.coarse {
407 opts = append(opts, harness.WithCoarsePointer())
408 }
409 noscript = noscript || leg.noscript
410 rig := harness.New(t, func(string) http.Handler { return srv }, opts...)
411 ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Minute)
412 defer cancel()
413 mustRun(t, ctx, chromedp.EmulateViewport(leg.w, leg.h))
414 probes, exempt, keyed := floorProbes(leg.w >= 800)
415 before := srv.snapshot()
416 measured := 0
417 for _, st := range states {
418 key := st.Name + "/" + st.State
419 where := fmt.Sprintf("%s, %s", leg.name, key)
420 path := st.Path
421 if leg.noscript {
422 path = st.NoScript
423 }
424 mustRun(t, ctx, chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body", chromedp.ByQuery))
425 settleUntil(t, ctx, `document.readyState === "complete"`)
426 mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise))
427 requirePointer(t, ctx, leg.coarse)
428
429 if leg.coarse {
430 var raw string
431 mustRun(t, ctx, chromedp.Evaluate(fontsJS, &raw))
432 var fonts []fontReading
433 if err := json.Unmarshal([]byte(raw), &fonts); err != nil {
434 t.Fatalf("%s: reading fonts (%q): %v", where, raw, err)
435 }
436 for _, f := range fonts {
437 if f.Px < 16 {
438 t.Errorf("%s: %s is %vpx; a phone zooms the page when it is focused", where, f.Name, f.Px)
439 }
440 }
441 }
442 c, i := assertIconSizes(t, where, readIcons(t, ctx, where))
443 carets, icons = carets+c, icons+i
444
445 var raw string
446 mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf("%s(%s, %s)", floorsJS, mustJSON(t, probes), mustJSON(t, exempt)), &raw, awaitPromise))
447 var got floorReading
448 if err := json.Unmarshal([]byte(raw), &got); err != nil {
449 t.Fatalf("%s: reading the floors (%q): %v", where, raw, err)
450 }
451 assertTargetsAt(t, where+", overlays closed", got.Closed, leg.floor)
452 measured += len(got.Closed)
453 for n, o := range got.Overlays {
454 assertTargetsAt(t, fmt.Sprintf("%s, overlay %d open", where, n), o, leg.floor)
455 measured += len(o)
456 }
457 for name, l := range got.Lists {
458 if len(l) == 0 {
459 t.Errorf("%s: %s opened with no options to measure", where, name)
460 }
461 assertTargetsAt(t, where+", "+name, l, leg.floor)
462 measured += len(l)
463 }
464 for _, name := range got.Failed {
465 t.Errorf("%s: %s never opened; its options were not measured", where, name)
466 }
467 for _, sel := range got.Unmeasured {
468 t.Errorf("%s: %s (%s) is rendered and was never measured; the idiom dropped out of the floor", where, sel, keyed[sel])
469 }
470 for _, g := range got.Gaps {
471 if g.Gap < 3 {
472 t.Errorf("%s, %s: in %s the icon is %.1fpx from the word %s it; it touches the word", where, g.Dir, g.Name, g.Gap, g.Side)
473 }
474 }
475 here := map[string]bool{}
476 for sel, n := range got.Counts {
477 seen[sel] += n
478 if n > 0 {
479 here[sel] = true
480 }
481 }
482 switch {
483 case leg.noscript:
484 with := withScripts[key]
485 if with == nil {
486 t.Fatalf("%s: no record of what the scripted leg measured on this state", where)
487 }
488 for sel := range with {
489 _, built := scriptBuilt[sel]
490 _, shown := scriptShown[st.Name+" "+sel]
491 if !here[sel] && shown {
492 shownUsed[st.Name+" "+sel] = true
493 }
494 if !here[sel] && !built && !shown {
495 t.Errorf("%s: %s (%s) is measured with scripts and not without; the component does not work with no script", where, sel, keyed[sel])
496 }
497 }
498 for sel := range here {
499 if !with[sel] {
500 t.Errorf("%s: %s (%s) is measured without scripts and not with them; the scripted page hides a control the plain one shows", where, sel, keyed[sel])
501 }
502 }
503 case !leg.coarse:
504 withScripts[key] = here
505 }
506 rig.Screen("body", where)
507 }
508 after := srv.snapshot()
509 missing := 0
510 for key, st := range want {
511 path := st.Path
512 if leg.noscript {
513 path = st.NoScript
514 }
515 if after[path]-before[path] < 1 {
516 missing++
517 t.Errorf("%s: the manifest's %s was never served on this leg", leg.name, key)
518 }
519 }
520 if measured < 100 {
521 t.Errorf("%s: measured %d controls over %d states; the instrument read next to nothing", leg.name, measured, len(want)-missing)
522 }
523 t.Logf("%s: %d of the manifest's %d states served, %d controls measured", leg.name, len(want)-missing, len(want), measured)
524 })
525 }
526 for key := range scriptShown {
527 if noscript && !shownUsed[key] {
528 t.Errorf("scriptShown excuses %s with no script, and no state needed it; drop the entry or fix its key", key)
529 }
530 }
531 if carets == 0 || icons == 0 {
532 t.Errorf("read %d caret icons and %d other icons in controls across every state; the examples must show at least one of each", carets, icons)
533 }
534 for _, e := range tapInventory {
535 if strings.HasPrefix(e.Probe, "elsewhere:") {
536 continue
537 }
538 narrow := strings.HasPrefix(e.Probe, "narrow:")
539 if narrow && !phone {
540 continue
541 }
542 sel := strings.TrimPrefix(strings.TrimPrefix(e.Probe, "narrow:"), "modal:")
543 _, pinned := floorGaps[sel]
544 switch {
545 case seen[sel] == 0 && !pinned && narrow == phone:
546 t.Errorf("no example state renders %s (%s), and it is not a pinned gap; the examples lost the idiom", sel, e.Idiom)
547 case seen[sel] > 0 && pinned:
548 t.Errorf("%s (%s) is pinned as a gap in floorGaps but an example renders it; drop the pin", sel, e.Idiom)
549 }
550 }
551}
552