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 drive helpers every browser test in this package shares: the
4// fixture server, the depth page, the pseudo-state inspector, the
5// probes and the readers. Ported from rastrillo's ui/ test files at
6// the seed commit, each with every dependency it reaches, so the drives that
7// follow can be ported without them.
8package check
9
10import (
11 "context"
12 "encoding/json"
13 "fmt"
14 "io"
15 "net/http"
16 "regexp"
17 "strings"
18 "testing"
19 "time"
20
21 "github.com/chromedp/cdproto/cdp"
22 "github.com/chromedp/cdproto/css"
23 "github.com/chromedp/cdproto/dom"
24 "github.com/chromedp/cdproto/emulation"
25 "github.com/chromedp/cdproto/runtime"
26 "github.com/chromedp/chromedp"
27
28 "amadan.net/rastrillo/fiesta"
29 "amadan.net/rastrillo/fiesta/internal/harness"
30)
31
32// ── The fixture server (ui/touch_browser_test.go) ────────────────────
33
34// sizingDoc is a page carrying the stylesheet, a theme, fiesta.js and
35// busy.js (light dismiss and the busy spinner, which the row-menu legs
36// drive), anchor.js (a menu's flip without anchor positioning), and the
37// scripts that enhance controls (select.js and datetime.js change what
38// a field renders, calendar.js draws the grid the calendar legs
39// measure). No CSP: this is a test page.
40func sizingDoc(title, body string) string {
41 return `<!doctype html><html lang="en" dir="ltr"><head><meta charset="utf-8">` +
42 `<meta name="viewport" content="width=device-width"><title>` + title + `</title>` +
43 `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme.css">` +
44 `<script defer src="/fiesta.js"></script><script defer src="/busy.js"></script><script defer src="/anchor.js"></script><script defer src="/select.js"></script>` +
45 `<script defer src="/calendar.js"></script><script defer src="/datetime.js"></script>` +
46 `</head><body>` + body + `</body></html>`
47}
48
49// sizingMux serves pages by path ("/" is the root only) and the assets
50// sizingDoc links, with the scripts from the embedded tree.
51func sizingMux(t *testing.T, pages map[string]string) *http.ServeMux {
52 t.Helper()
53 return sizingMuxWith(t, pages, fiesta.Script)
54}
55
56// sizingMuxWith is sizingMux with the scripts looked up through
57// scripts, so a drive can serve a release build's files in place of
58// the tree's. It serves /tokens.css and /theme.css (stylesheets),
59// /shell.css, /images/ (fixtureImages), /icons/<slug>.svg, and every
60// /<name>.js and /i18n/<name>.js through scripts, which answers false
61// for a file it does not have (a 404 the harness reports).
62func sizingMuxWith(t *testing.T, pages map[string]string, scripts func(name string) ([]byte, bool)) *http.ServeMux {
63 t.Helper()
64 mux := http.NewServeMux()
65 stylesheets(t, mux)
66 fixtureImages(mux)
67 mux.HandleFunc("GET /shell.css", func(w http.ResponseWriter, _ *http.Request) {
68 w.Header().Set("Content-Type", "text/css")
69 w.Write(fiesta.ShellCSS())
70 })
71 mux.HandleFunc("GET /icons/{file}", func(w http.ResponseWriter, r *http.Request) {
72 svg, ok := fiesta.Icon(strings.TrimSuffix(r.PathValue("file"), ".svg"))
73 if !ok || !strings.HasSuffix(r.PathValue("file"), ".svg") {
74 http.NotFound(w, r)
75 return
76 }
77 w.Header().Set("Content-Type", "image/svg+xml")
78 w.Write(svg)
79 })
80 serveScript := func(w http.ResponseWriter, r *http.Request, name string) {
81 if !strings.HasSuffix(name, ".js") {
82 http.NotFound(w, r)
83 return
84 }
85 body, ok := scripts(name)
86 if !ok {
87 http.NotFound(w, r)
88 return
89 }
90 w.Header().Set("Content-Type", "text/javascript")
91 w.Write(body)
92 }
93 mux.HandleFunc("GET /i18n/{file}", func(w http.ResponseWriter, r *http.Request) {
94 serveScript(w, r, "i18n/"+r.PathValue("file"))
95 })
96 // One segment, after every literal page path: a page a drive
97 // registers at /x wins over this, so it only ever answers for a
98 // script, or 404s for anything else, as rastrillo's mux did.
99 mux.HandleFunc("GET /{file}", func(w http.ResponseWriter, r *http.Request) {
100 serveScript(w, r, r.PathValue("file"))
101 })
102 for path, html := range pages {
103 html, pattern := html, "GET "+path
104 if path == "/" {
105 pattern = "GET /{$}"
106 }
107 mux.HandleFunc(pattern, func(w http.ResponseWriter, _ *http.Request) {
108 w.Header().Set("Content-Type", "text/html; charset=utf-8")
109 fmt.Fprint(w, html)
110 })
111 }
112 return mux
113}
114
115// sizingRig boots one browser, touch or mouse. Two rigs rather than one
116// switched: the pointer is a launch flag (harness.WithCoarsePointer).
117func sizingRig(t *testing.T, coarse bool, pages map[string]string, opts ...harness.Option) *harness.Rig {
118 t.Helper()
119 if coarse {
120 opts = append(opts, harness.WithCoarsePointer())
121 }
122 return harness.New(t, func(string) http.Handler { return sizingMux(t, pages) }, opts...)
123}
124
125// requirePointer is every touch leg's control: a touch leg that is
126// really running on a fine pointer would pass on desktop sizes, so it
127// fails first.
128func requirePointer(t *testing.T, ctx context.Context, coarse bool) {
129 t.Helper()
130 var got bool
131 if err := chromedp.Run(ctx, chromedp.Evaluate(`matchMedia("(pointer: coarse)").matches`, &got)); err != nil {
132 t.Fatalf("reading the pointer: %v", err)
133 }
134 if got != coarse {
135 t.Fatalf("(pointer: coarse) is %v, this leg needs %v; the rig is not the device this leg claims to measure", got, coarse)
136 }
137}
138
139// tokensJS reads the four type tokens as pixels, the body size, and the
140// two halves of the query, so a leg can say which half it exercised.
141const tokensJS = `(() => {
142 const probe = v => { const s = document.createElement("span"); s.style.fontSize = "var(" + v + ")"; document.body.appendChild(s); const px = parseFloat(getComputedStyle(s).fontSize); s.remove(); return px; };
143 return JSON.stringify({Lg: probe("--rst-fs-lg"), Base: probe("--rst-fs-base"), Sm: probe("--rst-fs-sm"), Xs: probe("--rst-fs-xs"),
144 Body: parseFloat(getComputedStyle(document.body).fontSize), Coarse: matchMedia("(pointer: coarse)").matches, Width: innerWidth});
145})()`
146
147type typeReading struct {
148 Lg, Base, Sm, Xs, Body float64
149 Coarse bool
150 Width int
151}
152
153// fontsJS reads every text-entry control's computed size, visible or
154// not — a size is computed for a field inside a closed menu too, and a
155// field that zooms when its menu opens is the bug.
156const fontsJS = `(() => {
157 const TEXTY = 'input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=range]):not([type=submit]):not([type=button]):not([type=color]):not([type=file]), select, textarea';
158 const out = [];
159 document.querySelectorAll(TEXTY).forEach(el => out.push({
160 Name: el.id || el.name || el.getAttribute("aria-label") || el.outerHTML.slice(0, 80),
161 Px: parseFloat(getComputedStyle(el).fontSize)}));
162 return JSON.stringify(out);
163})()`
164
165type fontReading struct {
166 Name string
167 Px float64
168}
169
170// ── The routes the fixtures share (ui/shell_browser_test.go, ui/mediagrid_browser_test.go) ──
171
172// stylesheets wires the two routes every page needs: the library
173// stylesheet under test, and a theme to fill in its colour and radius
174// tokens. Without the theme --rst-radius is undefined and the corner
175// assertions would read 0px for the wrong reason.
176func stylesheets(t *testing.T, mux *http.ServeMux) {
177 t.Helper()
178 mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) {
179 w.Header().Set("Content-Type", "text/css")
180 w.Write(fiesta.TokensCSS())
181 })
182 mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) {
183 css, ok := fiesta.ThemeCSS(fiesta.ThemeNames()[0])
184 if !ok {
185 http.Error(w, "no theme", http.StatusInternalServerError)
186 return
187 }
188 w.Header().Set("Content-Type", "text/css")
189 w.Write(css)
190 })
191}
192
193// fixtureImages serves the pictures the shared fixtures name under
194// /images/, as one small SVG, for every drive that renders a picture:
195// the harness reports a 404 as a problem on the page, and a media-grid
196// fixture with no picture to load would fail a drive about spelling or
197// target size for a reason that is neither.
198func fixtureImages(mux *http.ServeMux) {
199 mux.HandleFunc("GET /images/", func(w http.ResponseWriter, _ *http.Request) {
200 w.Header().Set("Content-Type", "image/svg+xml")
201 fmt.Fprint(w, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3" width="400" height="300"><rect width="4" height="3" fill="#cfd8e3"/></svg>`)
202 })
203}
204
205// ── The depth fixture and its readers (ui/depth_browser_test.go) ─────
206
207// depthBody is the page every depth drive reads: both spellings of
208// every component the depth language touches, with stable ids. It
209// links no light.js; lightDoc adds the tag.
210// markup-spelling: old-spelling begin — the depth fixture carries both spellings of each component it touches, so every rule is read in each.
211const depthBody = `<div rst-page>
212<p id="btns"><button rst-btn id="b-plain">Plain</button> <button class="rst-btn" id="b-plain-c">Plain</button> <button rst-btn="primary" id="b-primary">Save</button> <button class="rst-btn rst-btn--primary" id="b-primary-c">Save</button> <button rst-btn="danger" id="b-danger">Delete</button> <button rst-btn="ghost" id="b-ghost">Cancel</button> <a rst-btn="primary lg" href="#" id="b-link">Go</a> <button rst-btn disabled id="b-off">Off</button> <button rst-btn aria-busy="true" id="b-busy"><span rst-spin></span>Busy</button></p>
213<section rst-box id="box"><p>A box.</p></section>
214<div rst-stats id="stats"><div rst-stat><span rst-stat-label>Open</span><span rst-stat-num>12</span></div></div>
215<div rst-list id="list"><div rst-row id="lra"><span rst-row-main><a href="#lra" id="lra-link">Release notes</a></span></div></div>
216<div rst-card id="card" style="--rst-cols: 1fr auto">
217<div rst-lrow="head"><span>Name</span><span></span></div>
218<div rst-lrow id="row-a"><a class="rst-nm" href="#a" id="link-a">Alpha</a><span class="rst-cell-mut">one</span></div>
219<div class="rst-lrow" id="row-c"><a class="rst-nm" href="#c" id="link-c">Class row</a><span class="rst-cell-mut">two</span></div>
220<div rst-lrow id="row-inert"><span class="rst-nm">Gamma</span><span class="rst-cell-mut">three</span></div>
221</div>
222<div rst-list id="media-list"><div rst-media><div rst-media-tile id="tile"><div rst-media-frame><img src="/images/a.svg" alt=""></div><div rst-media-caption><div rst-media-name><a href="#t" id="tile-link">Picture</a></div></div></div></div></div>
223<div rst-shell-sidebar id="sidebar"><nav rst-shell-rail><div rst-shell-nav><a href="#x" id="nav-x">Overview</a><a href="#y" aria-current="page" id="nav-y">Requests</a></div></nav></div>
224<div rst-shell-console id="console"><nav rst-shell-rail><div rst-shell-nav><a href="#cx" id="cnav-x">Overview</a><a href="#cy" aria-current="page" id="cnav-y">Requests</a></div></nav></div>
225<div rst-shell-topbar id="topbar"><div rst-shell-tail><div rst-shell-nav><a href="#tx" id="tnav-x">Overview</a><a href="#ty" aria-current="page" id="tnav-y">Requests</a></div></div></div>
226<nav rst-seg-tabs id="tabs"><a href="#t1" id="tab-1">Open</a><a href="#t2" aria-current="page" id="tab-2">Closed</a></nav>
227<nav rst-pagination id="pages"><a href="#p1" id="page-1">1</a><a href="#p2" aria-current="page" id="page-2">2</a></nav>
228<details rst-dropdown id="dd"><summary id="dd-sum">Filter</summary><div rst-dropdown-menu id="dd-menu"><a href="#m1" id="menu-a">All</a><a href="#m2" aria-current="true" id="menu-cur">Open</a><button type="button" id="menu-btn">Archive</button><details rst-dropdown id="dd-nested"><summary id="dd-nested-sum">More</summary><div rst-dropdown-menu><a href="#n1">Nested</a></div></details></div></details>
229<div rst-combo><div rst-combo-list role="listbox"><div rst-combo-option role="option" id="opt-a">Alpha</div><div rst-combo-option class="is-active" role="option" id="opt-active">Beta</div></div></div>
230<p><label rst-switch id="sw"><input type="checkbox"><span rst-switch-track aria-hidden="true"></span> Email me</label></p>
231<p><input rst-input id="field" value="x"></p>
232<div rst-shell-sidebar="index" id="isidebar"><nav rst-shell-rail><div rst-shell-nav><a href="#ix" id="inav-x">Overview</a><a href="#iy" aria-current="page" id="inav-y">Requests</a></div></nav></div>
233<p><label rst-switch id="sw-on"><input type="checkbox" checked><span rst-switch-track aria-hidden="true"></span> Email me</label></p>
234<div rst-tblock id="tb"><label rst-tblock-head id="tb-head"><input type="checkbox" checked><span rst-switch-track aria-hidden="true"></span><span rst-tblock-title>Block</span></label><div rst-tblock-body>Body</div></div>
235<p><textarea rst-textarea id="area">x</textarea></p>
236<form rst-search id="search"><input type="search" id="search-in" value="x"></form>
237</div>`
238
239// markup-spelling: old-spelling end
240
241func depthDoc(t *testing.T) map[string]string {
242 t.Helper()
243 return map[string]string{"/": sizingDoc("depth", depthBody)}
244}
245
246// still freezes the page (C14): no transition, no animation, and the
247// light held straight above if light.js is on the page. Every drive
248// that reads a state snapshot calls it first, because a hover read in
249// the first 250ms of a 0.25s transition is a number the test did not
250// choose. The injected sheet is test-only; nothing shipped carries an
251// !important. It also enables the inspector for forceState (inspect).
252func still(t *testing.T, ctx context.Context) {
253 t.Helper()
254 inspect(t, ctx)
255 mustRun(t, ctx, chromedp.Evaluate(`(() => { const s = document.createElement("style"); s.textContent = "*, *::before, *::after { transition: none !important; animation: none !important; }"; document.head.append(s); document.dispatchEvent(new CustomEvent("rst:light", {detail: {x: 0, y: -1, hold: true}})); return true; })()`, nil))
256}
257
258// inspect enables the DOM and CSS domains once for the page, which
259// forceState needs: enabling them again per call cleared every
260// pseudo-state forced so far, so a drive that pinned hover on two
261// buttons read the first one at rest. On its own, for a drive that
262// forces a state and then watches a transition run (a row's lift
263// easing away under an open menu), which still() would freeze.
264func inspect(t *testing.T, ctx context.Context) {
265 t.Helper()
266 mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
267 if err := dom.Enable().Do(c); err != nil {
268 return err
269 }
270 if err := css.Enable().Do(c); err != nil {
271 return err
272 }
273 // One DOM.getDocument per page: every call invalidates the node
274 // ids handed out before it, and a forced pseudo-state is keyed
275 // by node id, so a second call dropped the first button's hover.
276 root, err := dom.GetDocument().Do(c)
277 if err != nil {
278 return err
279 }
280 forcedRoot = root.NodeID
281 return nil
282 }))
283}
284
285// forcedRoot is the page's document node, set by inspect() for forceState.
286var forcedRoot cdp.NodeID
287
288// forceState pins pseudo-classes on one element through the inspector
289// (what DevTools' :hov panel uses). The list REPLACES the element's
290// forced set, so a drive that wants hover and focus together passes
291// both in one call; states on other elements stay pinned. still() or
292// inspect() must have run on the page first.
293func forceState(t *testing.T, ctx context.Context, selector string, states ...string) {
294 t.Helper()
295 if states == nil {
296 states = []string{}
297 }
298 if forcedRoot == cdp.EmptyNodeID {
299 t.Fatal("forceState before still() or inspect(): the document node is not known")
300 }
301 mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
302 id, err := dom.QuerySelector(forcedRoot, selector).Do(c)
303 if err != nil {
304 return err
305 }
306 if id == cdp.EmptyNodeID {
307 return fmt.Errorf("forceState: no element matches %q", selector)
308 }
309 return css.ForcePseudoState(id, states).Do(c)
310 }))
311}
312
313// computed reads one computed property of one element, with any
314// color(srgb …) or oklch(…) in it rewritten as rgb()/rgba(): a derived
315// day token serialises as one of those, and every literal in the depth
316// drives is an eight-bit triple (canonColours, colourhelp_test.go).
317func computed(t *testing.T, ctx context.Context, selector, prop string) string {
318 t.Helper()
319 var out string
320 mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+jsStr(selector)+`)).getPropertyValue(`+jsStr(prop)+`)`, &out))
321 return canonColours(strings.TrimSpace(out))
322}
323
324func jsStr(s string) string {
325 b, _ := json.Marshal(s)
326 return string(b)
327}
328
329func rect(t *testing.T, ctx context.Context, selector string) (x, y, w, h float64) {
330 t.Helper()
331 var r struct{ X, Y, W, H float64 }
332 at(t, ctx, `(() => { const r = document.querySelector(`+jsStr(selector)+`).getBoundingClientRect(); return JSON.stringify({X: r.left, Y: r.top, W: r.width, H: r.height}); })()`, &r)
333 return r.X, r.Y, r.W, r.H
334}
335
336func open(t *testing.T, ctx context.Context, rig *harness.Rig, path string, reduce bool) {
337 t.Helper()
338 if reduce {
339 mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}}))
340 }
341 mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#field", chromedp.ByQuery))
342}
343
344// pressFallbackRig serves the depth fixture with tokens.css altered by
345// edit, over the sizing mux's copy.
346func pressFallbackRig(t *testing.T, edit func(string) string, opts ...harness.Option) *harness.Rig {
347 t.Helper()
348 tokens := []byte(edit(string(fiesta.TokensCSS())))
349 return harness.New(t, func(string) http.Handler {
350 mux := sizingMux(t, depthDoc(t))
351 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
352 if r.URL.Path == "/tokens.css" {
353 w.Header().Set("Content-Type", "text/css")
354 w.Write(tokens)
355 return
356 }
357 mux.ServeHTTP(w, r)
358 })
359 }, opts...)
360}
361
362// ── Running and reading (ui/rowmenu_browser_test.go, ui/rows_browser_test.go, ui/select_browser_test.go) ──
363
364// mustRun is chromedp.Run that fails the test on a browser error: a
365// swallowed error would leave the leg reading a page that never got
366// where the leg thinks it is, and passing on it.
367func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) {
368 t.Helper()
369 if err := chromedp.Run(ctx, actions...); err != nil {
370 t.Fatal(err)
371 }
372}
373
374// at evaluates a JS expression returning JSON into v.
375func at(t *testing.T, ctx context.Context, js string, v any, opts ...chromedp.EvaluateOption) {
376 t.Helper()
377 var raw string
378 if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw, opts...)); err != nil {
379 t.Fatalf("evaluating: %v\n%s", err, js)
380 }
381 if err := json.Unmarshal([]byte(raw), v); err != nil {
382 t.Fatalf("decoding %q: %v", raw, err)
383 }
384}
385
386// point is a place on screen and what elementFromPoint finds there.
387type point struct {
388 X, Y float64
389 Hit string
390}
391
392// probe returns the point at (x, y) in the box of sel, where fx/fy are
393// fractions of the width and height and dx/dy pixel offsets from that.
394//
395// It reads the page as it will be painted, two frames either side of
396// its scroll. A script may answer the last action, or this scroll, in
397// its next animation frame: select.js places an opened list and
398// scrolls its active row into view there. Read at once, the target
399// moves whenever that frame falls between the reading and the click,
400// which a loaded machine makes likely: a probe of Option 7 in a just
401// opened combobox clicked Option 3. The frames before the scroll let
402// queued work land so it cannot undo the scroll; the frames after let
403// the page answer the scroll itself.
404func probe(t *testing.T, ctx context.Context, sel string, fx, fy, dx, dy float64) point {
405 t.Helper()
406 return probeScripted(t, ctx, true, sel, fx, fy, dx, dy)
407}
408
409// probeScripted is probe for a leg that may run with script execution
410// disabled. There no frame callback ever runs, so a wait for one never
411// ends (the evaluation fails with "Promise was collected"); nor can a
412// script have queued work for a frame, so there is nothing to wait for.
413func probeScripted(t *testing.T, ctx context.Context, scripts bool, sel string, fx, fy, dx, dy float64) point {
414 t.Helper()
415 var p point
416 at(t, ctx, fmt.Sprintf(`(async () => { const frames = () => %v ? new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) : null;
417 await frames(); const el = document.querySelector(%q); el.scrollIntoView({block: "center", inline: "center"}); await frames(); const r = el.getBoundingClientRect();
418 const x = r.left + r.width * %v + %v, y = r.top + r.height * %v + %v; const h = document.elementFromPoint(x, y);
419 return JSON.stringify({X: x, Y: y, Hit: h ? (h.id || h.tagName) : "nothing"}); })()`, scripts, sel, fx, dx, fy, dy), &p, awaitPromise)
420 return p
421}
422
423func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
424 return p.WithAwaitPromise(true)
425}
426
427// ── Target readings and settling (ui/sizing_browser_test.go) ─────────
428
429// targetReading is one control, measured as its activation area.
430type targetReading struct {
431 Name, Hit string
432 W, H float64
433 Fits, Owns, Inline bool
434}
435
436func readTargets(t *testing.T, ctx context.Context, js string) []targetReading {
437 t.Helper()
438 var raw string
439 if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw)); err != nil {
440 t.Fatalf("measuring targets: %v", err)
441 }
442 var got []targetReading
443 if err := json.Unmarshal([]byte(raw), &got); err != nil {
444 t.Fatalf("reading targets (%q): %v", raw, err)
445 }
446 return got
447}
448
449// settleUntil polls a page expression until it is true or 10s pass.
450func settleUntil(t *testing.T, ctx context.Context, expr string) {
451 t.Helper()
452 deadline := time.Now().Add(10 * time.Second)
453 for time.Now().Before(deadline) {
454 var yes bool
455 // Errors are tolerated: an evaluation that lands while a document
456 // is being swapped in fails, and the next one reads the new page.
457 if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &yes)); err == nil && yes {
458 return
459 }
460 time.Sleep(100 * time.Millisecond)
461 }
462 t.Fatalf("never became true within 10s: %s", expr)
463}
464
465// ── Anchor positioning (internal/designsystem/galleryrig/rig.go) ─────
466
467// WithoutAnchorPositioning replaces the page's tokens.css with a copy
468// whose anchor-positioning @supports condition can never hold, which is
469// what an engine without the feature gets: the menu panel positioned
470// absolutely under its summary instead of fixed against it.
471const WithoutAnchorPositioning = `(async () => {
472 const link = document.querySelector('link[href*="/tokens."][href$=".css"]');
473 const css = await (await fetch(link.href)).text();
474 const style = document.createElement("style");
475 style.textContent = css.replaceAll("@supports (position-area: block-end) and (position-try-fallbacks: flip-block)", "@supports (position-area: rastrillo-no-such-value)");
476 link.replaceWith(style);
477 return true;
478})()`
479
480// RequireAnchorPositioning is the control for every leg that runs with
481// and without the feature: the named menu panel computes position:
482// fixed when anchor positioning applies and absolute when it does not.
483// A leg that asked for "without" and still got fixed would pass on the
484// path it claims to have left. selector picks one panel rather than
485// "the first [rst-dropdown-menu] on the page", because a page can carry
486// more than one (the bar's and the rail foot's), and the first found is
487// not necessarily the one the leg is driving.
488func RequireAnchorPositioning(t *testing.T, ctx context.Context, where, selector string, want bool) {
489 t.Helper()
490 var pos string
491 if err := chromedp.Run(ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+jsStr(selector)+`)).position`, &pos)); err != nil {
492 t.Fatalf("%s: reading the menu panel's position: %v", where, err)
493 }
494 if (pos == "fixed") != want {
495 t.Fatalf("%s: the menu panel is position: %s; this leg needs anchor positioning %v", where, pos, want)
496 }
497}
498
499// ── The other spelling (ui/markup_v3_browser_test.go) ────────────────
500
501// classSpelling translates markup the other way — rst- attributes back
502// into a class list — with tokens.css's own class list as the key. That
503// key is unambiguous because TestNoTwoClassesWantTheSameAttribute
504// holds: no two classes want one attribute, so no attribute has two
505// classes to choose between.
506//
507// It exists because of the flip. The partials and the styleguide hand
508// this drive attribute markup now, and a fixture half in each spelling
509// would compare identical halves and prove nothing about them.
510func classSpelling(t *testing.T, html string) string {
511 t.Helper()
512 byAttr := map[string]string{}
513 byVariant := map[string]string{}
514 for _, class := range sortedKeys(styledClasses(t, string(fiesta.TokensCSS()))) {
515 name, variant, ok := attributeFor(class)
516 if !ok {
517 continue
518 }
519 if variant == "" {
520 byAttr[name] = class
521 continue
522 }
523 byVariant[name+"~="+variant] = class
524 }
525
526 out := openTagInFixture.ReplaceAllStringFunc(html, func(tag string) string {
527 // The class attribute comes out of the tag before the scan: its
528 // tokens follow whitespace exactly as an attribute does, and the
529 // seven utilities that live there must survive the round trip.
530 kept := ""
531 if m := classInMarkup.FindStringSubmatch(tag); m != nil {
532 kept = m[1]
533 }
534 stripped := classInMarkup.ReplaceAllString(tag, "")
535
536 var classes []string
537 rewritten := rstAttrInFixture.ReplaceAllStringFunc(stripped, func(m string) string {
538 g := rstAttrInFixture.FindStringSubmatch(m)
539 name, value := g[1], g[2]
540 if name == "rst-tone" {
541 return ` data-tone="` + value + `"`
542 }
543 base, known := byAttr[name]
544 if !known {
545 t.Errorf("the fixture writes [%s], which tokens.css has no class for — the round trip cannot be checked", name)
546 return m
547 }
548 classes = append(classes, base)
549 for _, variant := range strings.Fields(value) {
550 v, ok := byVariant[name+"~="+variant]
551 if !ok {
552 t.Errorf("the fixture writes %s=%q, which tokens.css has no class for", name, variant)
553 continue
554 }
555 classes = append(classes, v)
556 }
557 return ""
558 })
559 if kept != "" {
560 classes = append(classes, strings.Fields(kept)...)
561 }
562 if len(classes) == 0 {
563 return tag
564 }
565 // After the tag name, so the class lands where an author would
566 // have written it rather than at the end of the attribute list.
567 i := strings.IndexAny(rewritten, " \t\n>")
568 return rewritten[:i] + ` class="` + strings.Join(classes, " ") + `"` + rewritten[i:]
569 })
570 return out
571}
572
573// ── The fixture server's own test ────────────────────────────────────
574
575// scriptTags matches the script elements sizingDoc links, for a page
576// that must load without them.
577var scriptTags = regexp.MustCompile(`<script[^>]*></script>`)
578
579// TestTheDepthFixtureServesItsStylesheets is the fixture server's own
580// check, ahead of the drives that read it: the depth page, served with
581// no script tags, loads /tokens.css and /theme.css with a 200 each, the
582// theme's tokens resolve on the page, and the harness reports no
583// console error and no failed request. The scripts are left off so
584// this reads the stylesheets alone; TestTheFixturePageLoadsEveryAsset
585// below is the one that loads them.
586func TestTheDepthFixtureServesItsStylesheets(t *testing.T) {
587 pages := depthDoc(t)
588 pages["/"] = scriptTags.ReplaceAllString(pages["/"], "")
589 if strings.Contains(pages["/"], "<script") {
590 t.Fatalf("the depth page still links a script:\n%s", pages["/"])
591 }
592 rig := harness.New(t, func(string) http.Handler { return sizingMux(t, pages) })
593 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
594 defer cancel()
595
596 client := &http.Client{Timeout: 10 * time.Second}
597 for _, path := range []string{"/tokens.css", "/theme.css"} {
598 res, err := client.Get(rig.Origin + path)
599 if err != nil {
600 t.Fatalf("GET %s: %v", path, err)
601 }
602 body, err := io.ReadAll(res.Body)
603 res.Body.Close()
604 if err != nil {
605 t.Fatalf("reading GET %s: %v", path, err)
606 }
607 if res.StatusCode != http.StatusOK {
608 t.Errorf("GET %s answered %d, want 200", path, res.StatusCode)
609 }
610 if len(body) == 0 {
611 t.Errorf("GET %s answered an empty body", path)
612 }
613 }
614
615 mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#field", chromedp.ByQuery))
616 var radius string
617 mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.documentElement).getPropertyValue("--rst-radius").trim()`, &radius))
618 if radius == "" {
619 t.Errorf("--rst-radius is empty on the page; the theme did not apply")
620 }
621 rig.Screen("body", "the depth fixture")
622}
623
624// TestTheFixturePageLoadsEveryAsset is the smoke test the depth check
625// above defers to: sizingDoc's page, served as it is, and every
626// stylesheet and script it links answers 200 with a body, the harness
627// sees no console error and no failed request, and fiesta.js ran (its
628// guard is on the window). The count is pinned so a page that linked
629// nothing could not pass.
630func TestTheFixturePageLoadsEveryAsset(t *testing.T) {
631 rig := harness.New(t, func(string) http.Handler { return sizingMux(t, map[string]string{"/": sizingDoc("smoke", "<p>x</p>")}) })
632 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
633 defer cancel()
634
635 mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("p", chromedp.ByQuery))
636 var assets []string
637 at(t, ctx, `JSON.stringify([...document.querySelectorAll("link[rel=stylesheet], script[src]")].map(e => e.href || e.src))`, &assets)
638 if len(assets) != 8 {
639 t.Fatalf("the page links %d assets, want 8 (two stylesheets, six scripts): %q", len(assets), assets)
640 }
641 client := &http.Client{Timeout: 10 * time.Second}
642 for _, url := range assets {
643 res, err := client.Get(url)
644 if err != nil {
645 t.Fatalf("GET %s: %v", url, err)
646 }
647 body, err := io.ReadAll(res.Body)
648 res.Body.Close()
649 if err != nil {
650 t.Fatalf("reading GET %s: %v", url, err)
651 }
652 if res.StatusCode != http.StatusOK {
653 t.Errorf("GET %s answered %d, want 200", url, res.StatusCode)
654 }
655 if len(body) == 0 {
656 t.Errorf("GET %s answered an empty body", url)
657 }
658 }
659 var ran bool
660 mustRun(t, ctx, chromedp.Evaluate(`window.fiestaShim === true`, &ran))
661 if !ran {
662 t.Error("fiesta.js did not run on the page: window.fiestaShim is not set")
663 }
664 rig.Screen("body", "the smoke page with every asset")
665}
666