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
3package gallery
4
5import (
6 "context"
7 "fmt"
8 "net/http"
9 "testing"
10 "time"
11
12 "github.com/chromedp/chromedp"
13 "github.com/chromedp/chromedp/kb"
14
15 "amadan.net/rastrillo/fiesta"
16 "amadan.net/rastrillo/fiesta/internal/harness"
17)
18
19// The drives for the rail and its filter. The page ships one complete
20// list of links and the whole feature is what happens to that list
21// after a keystroke, which no reading of the bytes can see.
22
23// pageAt is one page's address at the mount.
24func pageAt(theme, locale, file string) string { return href(theme + "/" + locale + "/" + file) }
25
26// present fails the leg by name when sel is not on the page: the
27// drives below wait on the shell's own elements, and a page without
28// them should fail as an assertion, not as a wait that ran out.
29func present(t *testing.T, ctx context.Context, where, sel string) {
30 t.Helper()
31 var ok bool
32 mustRun(t, ctx, chromedp.Evaluate(`!!document.querySelector(`+fmt.Sprintf("%q", sel)+`)`, &ok))
33 if !ok {
34 t.Fatalf("%s: the page has no %s", where, sel)
35 }
36}
37
38// railState is one reading of the whole rail, taken at one instant.
39type railState struct {
40 Links int `json:"links"`
41 Shown []string `json:"shown"`
42 Pages []string `json:"pages"`
43 Folded int `json:"folded"`
44 Open []bool `json:"open"`
45 Empty bool `json:"empty"`
46 Focused string `json:"focused"`
47 Value string `json:"value"`
48 BoxSeen bool `json:"boxSeen"`
49}
50
51// railProbe reads the whole rail in one evaluation. Every "is it
52// there" question is asked of getComputedStyle, never of the .hidden
53// property: gallery.js sets the property, and what takes a rail link
54// off the screen is a rule in gallery.css outranking the shell's
55// display: block. A probe reading .hidden would pass with that rule
56// deleted and sixty "hidden" links painted down the page.
57const railProbe = `(() => {
58 const seen = el => getComputedStyle(el).display !== "none";
59 const links = Array.from(document.querySelectorAll("#ds-nav details a"));
60 const pages = Array.from(document.querySelectorAll("#ds-nav > a"));
61 const secs = Array.from(document.querySelectorAll("#ds-nav details"));
62 const box = document.querySelector("[data-ds-filter]");
63 const empty = document.querySelector("[data-ds-filter-empty]");
64 return {
65 links: links.length,
66 shown: links.filter(seen).map(a => a.getAttribute("href")),
67 pages: pages.filter(seen).map(a => a.getAttribute("href")),
68 folded: secs.filter(d => !seen(d)).length,
69 open: secs.map(d => d.open),
70 empty: seen(empty),
71 focused: document.activeElement ? document.activeElement.id : "",
72 value: box.value,
73 boxSeen: seen(box.closest(".ds-search")),
74 };
75})()`
76
77// showing reports whether the rail is showing a link to href.
78func showing(state railState, href string) bool {
79 for _, s := range state.Shown {
80 if s == href {
81 return true
82 }
83 }
84 return false
85}
86
87// discloseIndex is where one page kind's section sits among the rail's
88// <details>, which is not its position in pageKinds(): a page kind
89// with nothing to list draws as a plain link and takes no slot here.
90func discloseIndex(t *testing.T, s *site, kind string) int {
91 t.Helper()
92 at := 0
93 for _, pk := range s.pageKinds() {
94 if pk.Kind == kind {
95 return at
96 }
97 if pk.Nav != nil {
98 at++
99 }
100 }
101 t.Fatalf("no page kind %s", kind)
102 return -1
103}
104
105// TestTheSidebarFilterDrivesTheWholeJourney: type an example's name, a
106// section's, junk, press Escape, and the collapse a reader made by hand
107// which a search has to borrow and give back. The bug classes, each of
108// which leaves a page that renders perfectly: the filter matches
109// nothing or everything because the fold is applied to one side only;
110// a section whose every entry is hidden stays on screen as a heading
111// over a gap; No matches never appears or never goes; Escape does not
112// clear, or clears and leaves the rail filtered; a section the reader
113// collapsed is left open; the box is visible with scripts off.
114//
115// The fold is proved on the query side: an accented query finds a
116// plain label. The label side (a Spanish heading found by an
117// unaccented query) waits for the catalogs that answer in Spanish.
118func TestTheSidebarFilterDrivesTheWholeJourney(t *testing.T) {
119 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
120 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
121 defer cancel()
122 s := newTestSite(t, fiesta.FS(), mountPath)
123 theme := fiesta.ThemeNames()[0]
124 // A family page: the rail is the same on every page, and this is
125 // one whose own section is long, so a filter that folded the
126 // current section away would show up here.
127 url := rig.Origin + pageAt(theme, "en", "form.html")
128 section := func(kind string) string { return s.href(theme, "en", s.fileOf(kind)) }
129 on := func(name string) string { return s.href(theme, "en", name+".html") }
130 shells, _ := s.familyByKey("shells")
131
132 var fresh, scriptless, badge, family, titled, junk, cleared, handled, restored, folded, foldedTitle railState
133 var navWithNoJS int
134 typing := func(q string) chromedp.Tasks {
135 return chromedp.Tasks{
136 chromedp.Focus(`#ds-filter`, chromedp.ByQuery),
137 chromedp.Evaluate(`(() => {
138 const box = document.querySelector("[data-ds-filter]");
139 box.value = "";
140 box.dispatchEvent(new Event("input", {bubbles: true}));
141 })()`, nil),
142 chromedp.SendKeys(`#ds-filter`, q, chromedp.ByQuery),
143 }
144 }
145 mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(url), chromedp.WaitReady("body"))
146 present(t, ctx, "form.html", "#ds-filter")
147 mustRun(t, ctx,
148 chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery),
149 chromedp.Evaluate(railProbe, &fresh),
150 // The scriptless half, asked of the real engine: take the
151 // marker away and the box goes with it, while every link in
152 // the rail stays where it was.
153 chromedp.Evaluate(`(() => {
154 document.documentElement.removeAttribute("data-gallery-js");
155 const seen = el => getComputedStyle(el).display !== "none";
156 const links = Array.from(document.querySelectorAll("#ds-nav details a"));
157 const pages = Array.from(document.querySelectorAll("#ds-nav > a"));
158 const state = {
159 links: links.length,
160 shown: links.filter(seen).map(a => a.getAttribute("href")),
161 pages: pages.filter(seen).map(a => a.getAttribute("href")),
162 folded: 0, open: [], empty: false, focused: "", value: "",
163 boxSeen: seen(document.querySelector(".ds-search")),
164 };
165 document.documentElement.setAttribute("data-gallery-js", "on");
166 return state;
167 })()`, &scriptless),
168 chromedp.Evaluate(`document.querySelectorAll("#ds-nav details a").length`, &navWithNoJS),
169 typing("badge"), chromedp.Evaluate(railProbe, &badge),
170 // A section's name stands for the run under it, while the rest
171 // of the rail keeps only what matches on its own: "form" is the
172 // Form page's name and part of form-error's, which is in
173 // Display.
174 typing("form"), chromedp.Evaluate(railProbe, &family),
175 // A section's own name: the whole section, and nothing from any
176 // other. "shells" is not a substring of any entry anywhere else.
177 typing("shells"), chromedp.Evaluate(railProbe, &titled),
178 typing("zzqqxx"), chromedp.Evaluate(railProbe, &junk),
179 chromedp.KeyEvent(kb.Escape), chromedp.Evaluate(railProbe, &cleared),
180 // The same key as an untrusted event. Chromium clears a search
181 // field on Escape by itself, so the real keypress proves the
182 // journey and nothing about this file; a synthetic event gets no
183 // default action, so what clears the box here is gallery.js.
184 typing("zzqqxx"),
185 chromedp.Evaluate(`document.querySelector("[data-ds-filter]").dispatchEvent(
186 new KeyboardEvent("keydown", {key: "Escape", bubbles: true, cancelable: true}))`, nil),
187 chromedp.Evaluate(railProbe, &handled),
188 // A section the reader collapses by hand is opened by a query
189 // that finds something in it, and folded back when the query
190 // goes.
191 chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll("#ds-nav details")[%d].open = false`, discloseIndex(t, s, "display")), nil),
192 typing("badge"), chromedp.KeyEvent(kb.Escape), chromedp.Evaluate(railProbe, &restored),
193 // The fold, from the query's side: an accent typed into the box
194 // finds the plain label, and an accented section name finds
195 // the section.
196 typing("surfáces"), chromedp.Evaluate(railProbe, &folded),
197 typing("tókens"), chromedp.Evaluate(railProbe, &foldedTitle),
198 )
199
200 if !fresh.BoxSeen {
201 t.Error("the filter box is not visible to a reader who has JavaScript")
202 }
203 if fresh.Links < 50 {
204 t.Errorf("the rail holds %d links; the page has far more than that to link", fresh.Links)
205 }
206 if len(fresh.Shown) != fresh.Links {
207 t.Errorf("%d of %d rail links are hidden before anything is typed", fresh.Links-len(fresh.Shown), fresh.Links)
208 }
209 if fresh.Empty {
210 t.Error("the no-matches line is on screen before anything is typed")
211 }
212 var open int
213 for _, o := range fresh.Open {
214 if o {
215 open++
216 }
217 }
218 if open != 1 {
219 t.Errorf("%d of %d sidebar sections arrive open, want exactly 1 (the page you are on)", open, len(fresh.Open))
220 }
221 if at := discloseIndex(t, s, "form"); len(fresh.Open) > at && !fresh.Open[at] {
222 t.Errorf("the Form section (disclosure %d of %d) is not the one open on the form page", at, len(fresh.Open))
223 }
224 if scriptless.BoxSeen {
225 t.Error("the filter box is still visible without data-gallery-js; with scripts off it would look like a control that works")
226 }
227 if len(scriptless.Shown) != fresh.Links || navWithNoJS != fresh.Links {
228 t.Errorf("with the marker off the rail shows %d of %d links; the nav is complete with or without a script", len(scriptless.Shown), fresh.Links)
229 }
230 if !showing(badge, on("badge")) {
231 t.Error(`typing "badge" hid the badge example`)
232 }
233 if showing(badge, on("meter")) {
234 t.Error(`typing "badge" left the meter example on screen`)
235 }
236 if showing(badge, section("tokens")+"#tokens-accent") {
237 t.Error(`typing "badge" left a token group on screen`)
238 }
239 if badge.Folded == 0 {
240 t.Error(`typing "badge" folded no section away; Tokens, Shells and Demos have nothing in them that matches`)
241 }
242 if badge.Empty {
243 t.Error(`typing "badge" said there were no matches`)
244 }
245 if !showing(family, section("form")) {
246 t.Error(`typing "form" hid the Form section's own overview link`)
247 }
248 if !showing(family, on("field-url")) {
249 t.Error(`typing "form" hid field-url, which is in the section that matched`)
250 }
251 if showing(family, section("list-screen")) {
252 t.Error(`typing "form" left the List screen section on screen with nothing under it that matches`)
253 }
254 if !showing(family, on("form-error")) {
255 t.Error(`typing "form" hid form-error, which matches on its own name`)
256 }
257 for _, e := range shells.Examples {
258 if !showing(titled, on(e.Name)) {
259 t.Errorf(`typing a section's own name ("shells") did not reveal %s: %v`, e.Name, titled.Shown)
260 }
261 }
262 if want := len(shells.Examples) + 1; len(titled.Shown) != want {
263 t.Errorf(`typing "shells" left %d entries on screen, want the section's %d: %v`, len(titled.Shown), want, titled.Shown)
264 }
265 if !showing(titled, section("shells")) {
266 t.Errorf(`typing "shells" hid the section's own overview link: %v`, titled.Shown)
267 }
268 if len(junk.Shown) != 0 {
269 t.Errorf("junk left %d entries on screen: %v", len(junk.Shown), junk.Shown)
270 }
271 if len(junk.Pages) != 0 || len(fresh.Pages) == 0 {
272 t.Errorf("junk left %d of %d page links on screen; a page link filters like any other entry", len(junk.Pages), len(fresh.Pages))
273 }
274 if !junk.Empty {
275 t.Error("junk matched nothing and the page never said so")
276 }
277 if junk.Focused != "ds-filter" {
278 t.Errorf("focus moved to %q while typing; the filter must not take it anywhere", junk.Focused)
279 }
280 if cleared.Value != "" {
281 t.Errorf("Escape left %q in the box", cleared.Value)
282 }
283 if len(cleared.Shown) != cleared.Links {
284 t.Errorf("Escape cleared the box and left %d of %d entries hidden", cleared.Links-len(cleared.Shown), cleared.Links)
285 }
286 if cleared.Empty {
287 t.Error("Escape cleared the box and left the no-matches line up")
288 }
289 if cleared.Focused != "ds-filter" {
290 t.Errorf("Escape moved focus to %q", cleared.Focused)
291 }
292 if handled.Value != "" {
293 t.Errorf("a synthetic Escape left %q in the box; gallery.js is not handling the key, the browser is", handled.Value)
294 }
295 if len(handled.Shown) != handled.Links {
296 t.Errorf("a synthetic Escape cleared the box and left %d of %d entries hidden", handled.Links-len(handled.Shown), handled.Links)
297 }
298 if at := discloseIndex(t, s, "display"); len(restored.Open) <= at || restored.Open[at] {
299 t.Error("a section the reader collapsed by hand was left open by a search that has been cleared")
300 }
301 if len(restored.Shown) != restored.Links {
302 t.Errorf("after the search was cleared, %d of %d entries are still hidden", restored.Links-len(restored.Shown), restored.Links)
303 }
304 if !showing(folded, section("tokens")+"#tokens-surfaces-and-lines") {
305 t.Errorf(`"surfáces" did not find Surfaces and lines; the query is not folded: %v`, folded.Shown)
306 }
307 if !showing(foldedTitle, section("tokens")) || showing(foldedTitle, section("form")) {
308 t.Errorf(`"tókens" did not find the Tokens section alone: %v`, foldedTitle.Shown)
309 }
310}
311
312// TestSearchFindsWhatPeopleCallThings: the filter finds an example by
313// what a reader calls it, on desktop and on the phone index:
314// "checkbox" finds field-check, "dialog" the modal, "button" the
315// button, and junk says No matches with no page link left showing
316// beside it. On the index, a query swaps the rows for the tree it
317// filters, and a result opens the example's page.
318func TestSearchFindsWhatPeopleCallThings(t *testing.T) {
319 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
320 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
321 defer cancel()
322 theme := fiesta.ThemeNames()[0]
323 visible := func(sel string) string {
324 return `(() => { const e = document.querySelector('` + sel + `'); return !!e && e.getBoundingClientRect().height > 0; })()`
325 }
326 typeInto := func(c context.Context, where, q string) {
327 mustRun(t, c, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter"); i.value = ""; i.dispatchEvent(new Event("input")); return true; })()`, nil),
328 chromedp.SendKeys(`#ds-filter`, q, chromedp.ByQuery))
329 }
330 mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body"))
331 present(t, ctx, "form.html", "#ds-filter")
332 mustRun(t, ctx, chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery))
333 for q, name := range map[string]string{"checkbox": "field-check", "dialog": "modal", "button": "button", "CHECKBOX": "field-check"} {
334 where := "desktop, " + q
335 typeInto(ctx, where, q)
336 until(t, ctx, where, visible(`#ds-nav a[href$="/`+name+`.html"]`))
337 }
338 typeInto(ctx, "desktop, junk", "zzqxv")
339 until(t, ctx, "desktop, junk", visible(`[data-ds-filter-empty]`)+` && ![...document.querySelectorAll("#ds-nav a")].some(a => a.getBoundingClientRect().height > 0)`)
340
341 phone := phoneRig(t)
342 pctx, pcancel := context.WithTimeout(phone.Context(), 120*time.Second)
343 defer pcancel()
344 where := "the phone index"
345 mustRun(t, pctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(phone.Origin+pageAt(theme, "en", "index.html")), chromedp.WaitReady("body"))
346 present(t, pctx, where, ".ds-index")
347 mustRun(t, pctx, chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery), chromedp.SendKeys(`#ds-filter`, "checkbox", chromedp.ByQuery))
348 requireCoarse(t, pctx)
349 until(t, pctx, where+", rows swapped for the tree", `document.querySelector(".ds-index").getBoundingClientRect().height === 0 && `+visible(`#ds-nav a[href$="/field-check.html"]`))
350 mustRun(t, pctx, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter"); i.value = ""; i.dispatchEvent(new Event("input")); return true; })()`, nil))
351 until(t, pctx, where+", rows back", visible(`.ds-index`))
352 mustRun(t, pctx, chromedp.SendKeys(`#ds-filter`, "checkbox", chromedp.ByQuery),
353 chromedp.Evaluate(`document.querySelector('#ds-nav a[href$="/field-check.html"]').click(); true`, nil))
354 until(t, pctx, where+", field-check's page", `location.pathname.endsWith("/field-check.html") && document.readyState === "complete"`)
355}
356
357// TestTheRailWorksWithNoScript: with JavaScript off on a family page
358// the filter box is not shown, the page's own section arrives open
359// with every entry drawn, no rail link is hidden, and following an
360// entry lands on its page. The rail is a complete list of every page
361// before any script runs, and stays one when none does.
362func TestTheRailWorksWithNoScript(t *testing.T) {
363 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
364 ctx := noScripts(t, rig.Context())
365 ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
366 defer cancel()
367 theme := fiesta.ThemeNames()[0]
368 where := "day/en/form.html with scripts off"
369 mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body"))
370 requireScriptsOff(t, ctx, where)
371 present(t, ctx, where, "#ds-nav")
372 var raw struct {
373 BoxSeen bool
374 Links int
375 Hidden []string
376 Open []string
377 Boxed int
378 Section int
379 }
380 at(t, ctx, `(() => {
381 const seen = el => getComputedStyle(el).display !== "none";
382 const links = [...document.querySelectorAll("#ds-nav a")];
383 const open = [...document.querySelectorAll("#ds-nav details[open]")];
384 const section = open.length === 1 ? open[0].querySelectorAll("a").length : 0;
385 const boxed = open.length === 1 ? [...open[0].querySelectorAll("a")].filter(a => a.getBoundingClientRect().height > 0).length : 0;
386 return JSON.stringify({BoxSeen: seen(document.querySelector(".ds-search")), Links: links.length,
387 Hidden: links.filter(a => !seen(a)).map(a => a.getAttribute("href")),
388 Open: open.map(d => d.querySelector("summary").textContent.trim()), Boxed: boxed, Section: section});
389 })()`, &raw)
390 if raw.BoxSeen {
391 t.Error("the filter box is shown with scripts off; it would look like a control that works")
392 }
393 if raw.Links < 50 || len(raw.Hidden) != 0 {
394 t.Errorf("the rail holds %d links and hides %v with scripts off; every entry is drawn", raw.Links, raw.Hidden)
395 }
396 if len(raw.Open) != 1 || raw.Section == 0 || raw.Boxed != raw.Section {
397 t.Errorf("the open sections are %v with %d of %d entries drawn with a box; want the Form section alone, every entry visible", raw.Open, raw.Boxed, raw.Section)
398 }
399 mustRun(t, ctx, chromedp.Evaluate(`document.querySelector('#ds-nav a[href$="/field-text.html"]').click(); true`, nil))
400 until(t, ctx, where+", field-text followed", `location.pathname.endsWith("/field-text.html") && document.readyState === "complete"`)
401 requireScriptsOff(t, ctx, where+", on field-text")
402}
403