//go:build browser package gallery import ( "context" "fmt" "net/http" "testing" "time" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" ) // The drives for the rail and its filter. The page ships one complete // list of links and the whole feature is what happens to that list // after a keystroke, which no reading of the bytes can see. // pageAt is one page's address at the mount. func pageAt(theme, locale, file string) string { return href(theme + "/" + locale + "/" + file) } // present fails the leg by name when sel is not on the page: the // drives below wait on the shell's own elements, and a page without // them should fail as an assertion, not as a wait that ran out. func present(t *testing.T, ctx context.Context, where, sel string) { t.Helper() var ok bool mustRun(t, ctx, chromedp.Evaluate(`!!document.querySelector(`+fmt.Sprintf("%q", sel)+`)`, &ok)) if !ok { t.Fatalf("%s: the page has no %s", where, sel) } } // railState is one reading of the whole rail, taken at one instant. type railState struct { Links int `json:"links"` Shown []string `json:"shown"` Pages []string `json:"pages"` Folded int `json:"folded"` Open []bool `json:"open"` Empty bool `json:"empty"` Focused string `json:"focused"` Value string `json:"value"` BoxSeen bool `json:"boxSeen"` } // railProbe reads the whole rail in one evaluation. Every "is it // there" question is asked of getComputedStyle, never of the .hidden // property: gallery.js sets the property, and what takes a rail link // off the screen is a rule in gallery.css outranking the shell's // display: block. A probe reading .hidden would pass with that rule // deleted and sixty "hidden" links painted down the page. const railProbe = `(() => { const seen = el => getComputedStyle(el).display !== "none"; const links = Array.from(document.querySelectorAll("#ds-nav details a")); const pages = Array.from(document.querySelectorAll("#ds-nav > a")); const secs = Array.from(document.querySelectorAll("#ds-nav details")); const box = document.querySelector("[data-ds-filter]"); const empty = document.querySelector("[data-ds-filter-empty]"); return { links: links.length, shown: links.filter(seen).map(a => a.getAttribute("href")), pages: pages.filter(seen).map(a => a.getAttribute("href")), folded: secs.filter(d => !seen(d)).length, open: secs.map(d => d.open), empty: seen(empty), focused: document.activeElement ? document.activeElement.id : "", value: box.value, boxSeen: seen(box.closest(".ds-search")), }; })()` // showing reports whether the rail is showing a link to href. func showing(state railState, href string) bool { for _, s := range state.Shown { if s == href { return true } } return false } // discloseIndex is where one page kind's section sits among the rail's //
, which is not its position in pageKinds(): a page kind // with nothing to list draws as a plain link and takes no slot here. func discloseIndex(t *testing.T, s *site, kind string) int { t.Helper() at := 0 for _, pk := range s.pageKinds() { if pk.Kind == kind { return at } if pk.Nav != nil { at++ } } t.Fatalf("no page kind %s", kind) return -1 } // TestTheSidebarFilterDrivesTheWholeJourney: type an example's name, a // section's, junk, press Escape, and the collapse a reader made by hand // which a search has to borrow and give back. The bug classes, each of // which leaves a page that renders perfectly: the filter matches // nothing or everything because the fold is applied to one side only; // a section whose every entry is hidden stays on screen as a heading // over a gap; No matches never appears or never goes; Escape does not // clear, or clears and leaves the rail filtered; a section the reader // collapsed is left open; the box is visible with scripts off. // // The fold is proved on the query side: an accented query finds a // plain label. The label side (a Spanish heading found by an // unaccented query) waits for the catalogs that answer in Spanish. func TestTheSidebarFilterDrivesTheWholeJourney(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() s := newTestSite(t, fiesta.FS(), mountPath) theme := fiesta.ThemeNames()[0] // A family page: the rail is the same on every page, and this is // one whose own section is long, so a filter that folded the // current section away would show up here. url := rig.Origin + pageAt(theme, "en", "form.html") section := func(kind string) string { return s.href(theme, "en", s.fileOf(kind)) } on := func(name string) string { return s.href(theme, "en", name+".html") } shells, _ := s.familyByKey("shells") var fresh, scriptless, badge, family, titled, junk, cleared, handled, restored, folded, foldedTitle railState var navWithNoJS int typing := func(q string) chromedp.Tasks { return chromedp.Tasks{ chromedp.Focus(`#ds-filter`, chromedp.ByQuery), chromedp.Evaluate(`(() => { const box = document.querySelector("[data-ds-filter]"); box.value = ""; box.dispatchEvent(new Event("input", {bubbles: true})); })()`, nil), chromedp.SendKeys(`#ds-filter`, q, chromedp.ByQuery), } } mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(url), chromedp.WaitReady("body")) present(t, ctx, "form.html", "#ds-filter") mustRun(t, ctx, chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery), chromedp.Evaluate(railProbe, &fresh), // The scriptless half, asked of the real engine: take the // marker away and the box goes with it, while every link in // the rail stays where it was. chromedp.Evaluate(`(() => { document.documentElement.removeAttribute("data-gallery-js"); const seen = el => getComputedStyle(el).display !== "none"; const links = Array.from(document.querySelectorAll("#ds-nav details a")); const pages = Array.from(document.querySelectorAll("#ds-nav > a")); const state = { links: links.length, shown: links.filter(seen).map(a => a.getAttribute("href")), pages: pages.filter(seen).map(a => a.getAttribute("href")), folded: 0, open: [], empty: false, focused: "", value: "", boxSeen: seen(document.querySelector(".ds-search")), }; document.documentElement.setAttribute("data-gallery-js", "on"); return state; })()`, &scriptless), chromedp.Evaluate(`document.querySelectorAll("#ds-nav details a").length`, &navWithNoJS), typing("badge"), chromedp.Evaluate(railProbe, &badge), // A section's name stands for the run under it, while the rest // of the rail keeps only what matches on its own: "form" is the // Form page's name and part of form-error's, which is in // Display. typing("form"), chromedp.Evaluate(railProbe, &family), // A section's own name: the whole section, and nothing from any // other. "shells" is not a substring of any entry anywhere else. typing("shells"), chromedp.Evaluate(railProbe, &titled), typing("zzqqxx"), chromedp.Evaluate(railProbe, &junk), chromedp.KeyEvent(kb.Escape), chromedp.Evaluate(railProbe, &cleared), // The same key as an untrusted event. Chromium clears a search // field on Escape by itself, so the real keypress proves the // journey and nothing about this file; a synthetic event gets no // default action, so what clears the box here is gallery.js. typing("zzqqxx"), chromedp.Evaluate(`document.querySelector("[data-ds-filter]").dispatchEvent( new KeyboardEvent("keydown", {key: "Escape", bubbles: true, cancelable: true}))`, nil), chromedp.Evaluate(railProbe, &handled), // A section the reader collapses by hand is opened by a query // that finds something in it, and folded back when the query // goes. chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll("#ds-nav details")[%d].open = false`, discloseIndex(t, s, "display")), nil), typing("badge"), chromedp.KeyEvent(kb.Escape), chromedp.Evaluate(railProbe, &restored), // The fold, from the query's side: an accent typed into the box // finds the plain label, and an accented section name finds // the section. typing("surfáces"), chromedp.Evaluate(railProbe, &folded), typing("tókens"), chromedp.Evaluate(railProbe, &foldedTitle), ) if !fresh.BoxSeen { t.Error("the filter box is not visible to a reader who has JavaScript") } if fresh.Links < 50 { t.Errorf("the rail holds %d links; the page has far more than that to link", fresh.Links) } if len(fresh.Shown) != fresh.Links { t.Errorf("%d of %d rail links are hidden before anything is typed", fresh.Links-len(fresh.Shown), fresh.Links) } if fresh.Empty { t.Error("the no-matches line is on screen before anything is typed") } var open int for _, o := range fresh.Open { if o { open++ } } if open != 1 { t.Errorf("%d of %d sidebar sections arrive open, want exactly 1 (the page you are on)", open, len(fresh.Open)) } if at := discloseIndex(t, s, "form"); len(fresh.Open) > at && !fresh.Open[at] { t.Errorf("the Form section (disclosure %d of %d) is not the one open on the form page", at, len(fresh.Open)) } if scriptless.BoxSeen { t.Error("the filter box is still visible without data-gallery-js; with scripts off it would look like a control that works") } if len(scriptless.Shown) != fresh.Links || navWithNoJS != fresh.Links { 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) } if !showing(badge, on("badge")) { t.Error(`typing "badge" hid the badge example`) } if showing(badge, on("meter")) { t.Error(`typing "badge" left the meter example on screen`) } if showing(badge, section("tokens")+"#tokens-accent") { t.Error(`typing "badge" left a token group on screen`) } if badge.Folded == 0 { t.Error(`typing "badge" folded no section away; Tokens, Shells and Demos have nothing in them that matches`) } if badge.Empty { t.Error(`typing "badge" said there were no matches`) } if !showing(family, section("form")) { t.Error(`typing "form" hid the Form section's own overview link`) } if !showing(family, on("field-url")) { t.Error(`typing "form" hid field-url, which is in the section that matched`) } if showing(family, section("list-screen")) { t.Error(`typing "form" left the List screen section on screen with nothing under it that matches`) } if !showing(family, on("form-error")) { t.Error(`typing "form" hid form-error, which matches on its own name`) } for _, e := range shells.Examples { if !showing(titled, on(e.Name)) { t.Errorf(`typing a section's own name ("shells") did not reveal %s: %v`, e.Name, titled.Shown) } } if want := len(shells.Examples) + 1; len(titled.Shown) != want { t.Errorf(`typing "shells" left %d entries on screen, want the section's %d: %v`, len(titled.Shown), want, titled.Shown) } if !showing(titled, section("shells")) { t.Errorf(`typing "shells" hid the section's own overview link: %v`, titled.Shown) } if len(junk.Shown) != 0 { t.Errorf("junk left %d entries on screen: %v", len(junk.Shown), junk.Shown) } if len(junk.Pages) != 0 || len(fresh.Pages) == 0 { 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)) } if !junk.Empty { t.Error("junk matched nothing and the page never said so") } if junk.Focused != "ds-filter" { t.Errorf("focus moved to %q while typing; the filter must not take it anywhere", junk.Focused) } if cleared.Value != "" { t.Errorf("Escape left %q in the box", cleared.Value) } if len(cleared.Shown) != cleared.Links { t.Errorf("Escape cleared the box and left %d of %d entries hidden", cleared.Links-len(cleared.Shown), cleared.Links) } if cleared.Empty { t.Error("Escape cleared the box and left the no-matches line up") } if cleared.Focused != "ds-filter" { t.Errorf("Escape moved focus to %q", cleared.Focused) } if handled.Value != "" { t.Errorf("a synthetic Escape left %q in the box; gallery.js is not handling the key, the browser is", handled.Value) } if len(handled.Shown) != handled.Links { t.Errorf("a synthetic Escape cleared the box and left %d of %d entries hidden", handled.Links-len(handled.Shown), handled.Links) } if at := discloseIndex(t, s, "display"); len(restored.Open) <= at || restored.Open[at] { t.Error("a section the reader collapsed by hand was left open by a search that has been cleared") } if len(restored.Shown) != restored.Links { t.Errorf("after the search was cleared, %d of %d entries are still hidden", restored.Links-len(restored.Shown), restored.Links) } if !showing(folded, section("tokens")+"#tokens-surfaces-and-lines") { t.Errorf(`"surfáces" did not find Surfaces and lines; the query is not folded: %v`, folded.Shown) } if !showing(foldedTitle, section("tokens")) || showing(foldedTitle, section("form")) { t.Errorf(`"tókens" did not find the Tokens section alone: %v`, foldedTitle.Shown) } } // TestSearchFindsWhatPeopleCallThings: the filter finds an example by // what a reader calls it, on desktop and on the phone index: // "checkbox" finds field-check, "dialog" the modal, "button" the // button, and junk says No matches with no page link left showing // beside it. On the index, a query swaps the rows for the tree it // filters, and a result opens the example's page. func TestSearchFindsWhatPeopleCallThings(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() theme := fiesta.ThemeNames()[0] visible := func(sel string) string { return `(() => { const e = document.querySelector('` + sel + `'); return !!e && e.getBoundingClientRect().height > 0; })()` } typeInto := func(c context.Context, where, q string) { mustRun(t, c, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter"); i.value = ""; i.dispatchEvent(new Event("input")); return true; })()`, nil), chromedp.SendKeys(`#ds-filter`, q, chromedp.ByQuery)) } mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body")) present(t, ctx, "form.html", "#ds-filter") mustRun(t, ctx, chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery)) for q, name := range map[string]string{"checkbox": "field-check", "dialog": "modal", "button": "button", "CHECKBOX": "field-check"} { where := "desktop, " + q typeInto(ctx, where, q) until(t, ctx, where, visible(`#ds-nav a[href$="/`+name+`.html"]`)) } typeInto(ctx, "desktop, junk", "zzqxv") until(t, ctx, "desktop, junk", visible(`[data-ds-filter-empty]`)+` && ![...document.querySelectorAll("#ds-nav a")].some(a => a.getBoundingClientRect().height > 0)`) phone := phoneRig(t) pctx, pcancel := context.WithTimeout(phone.Context(), 120*time.Second) defer pcancel() where := "the phone index" mustRun(t, pctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(phone.Origin+pageAt(theme, "en", "index.html")), chromedp.WaitReady("body")) present(t, pctx, where, ".ds-index") mustRun(t, pctx, chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery), chromedp.SendKeys(`#ds-filter`, "checkbox", chromedp.ByQuery)) requireCoarse(t, pctx) until(t, pctx, where+", rows swapped for the tree", `document.querySelector(".ds-index").getBoundingClientRect().height === 0 && `+visible(`#ds-nav a[href$="/field-check.html"]`)) mustRun(t, pctx, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter"); i.value = ""; i.dispatchEvent(new Event("input")); return true; })()`, nil)) until(t, pctx, where+", rows back", visible(`.ds-index`)) mustRun(t, pctx, chromedp.SendKeys(`#ds-filter`, "checkbox", chromedp.ByQuery), chromedp.Evaluate(`document.querySelector('#ds-nav a[href$="/field-check.html"]').click(); true`, nil)) until(t, pctx, where+", field-check's page", `location.pathname.endsWith("/field-check.html") && document.readyState === "complete"`) } // TestTheRailWorksWithNoScript: with JavaScript off on a family page // the filter box is not shown, the page's own section arrives open // with every entry drawn, no rail link is hidden, and following an // entry lands on its page. The rail is a complete list of every page // before any script runs, and stays one when none does. func TestTheRailWorksWithNoScript(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx := noScripts(t, rig.Context()) ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() theme := fiesta.ThemeNames()[0] where := "day/en/form.html with scripts off" mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body")) requireScriptsOff(t, ctx, where) present(t, ctx, where, "#ds-nav") var raw struct { BoxSeen bool Links int Hidden []string Open []string Boxed int Section int } at(t, ctx, `(() => { const seen = el => getComputedStyle(el).display !== "none"; const links = [...document.querySelectorAll("#ds-nav a")]; const open = [...document.querySelectorAll("#ds-nav details[open]")]; const section = open.length === 1 ? open[0].querySelectorAll("a").length : 0; const boxed = open.length === 1 ? [...open[0].querySelectorAll("a")].filter(a => a.getBoundingClientRect().height > 0).length : 0; return JSON.stringify({BoxSeen: seen(document.querySelector(".ds-search")), Links: links.length, Hidden: links.filter(a => !seen(a)).map(a => a.getAttribute("href")), Open: open.map(d => d.querySelector("summary").textContent.trim()), Boxed: boxed, Section: section}); })()`, &raw) if raw.BoxSeen { t.Error("the filter box is shown with scripts off; it would look like a control that works") } if raw.Links < 50 || len(raw.Hidden) != 0 { t.Errorf("the rail holds %d links and hides %v with scripts off; every entry is drawn", raw.Links, raw.Hidden) } if len(raw.Open) != 1 || raw.Section == 0 || raw.Boxed != raw.Section { 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) } mustRun(t, ctx, chromedp.Evaluate(`document.querySelector('#ds-nav a[href$="/field-text.html"]').click(); true`, nil)) until(t, ctx, where+", field-text followed", `location.pathname.endsWith("/field-text.html") && document.readyState === "complete"`) requireScriptsOff(t, ctx, where+", on field-text") }