| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 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. |
| 24 | func 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. |
| 29 | func 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. |
| 39 | type 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. |
| 57 | const 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. |
| 78 | func 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. |
| 90 | func 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. |
| 118 | func 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. |
| 318 | func 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. |
| 362 | func 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 | |