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 "encoding/json"
8 "math"
9 "net/http"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/chromedp/chromedp"
15
16 "amadan.net/rastrillo/fiesta"
17 "amadan.net/rastrillo/fiesta/internal/harness"
18)
19
20// The frame document as a browser lays it out: the padding the frame
21// style gives a component and withholds from a whole page, the social
22// sign-in card as an app would show it, and a demo opened in a tab of
23// its own in the reader's scheme before first paint. The same demo
24// coming back from the back/forward cache is in the history package,
25// on full Chromium.
26
27// TestTheSocialSignInButtonsStackAndCarryTheirMarks: the social sign-in
28// screen is markup an app copies, so what it looks like in the frame is
29// what it will look like in the app. Rastrillo's first cut shipped two
30// forms that touched and two text-only buttons under a blurb promising
31// the companies' marks. Neither shows up in the bytes: a gap is a
32// computed box, and an icon that is present but sized off the text,
33// pushed off-centre or painted in a fixed colour that vanishes on a
34// dark surface is still present. So this reads the rendered frame, in
35// both schemes, because the Apple mark following the button's text
36// colour is only proven by a scheme that changes it. Ported onto the
37// signin example's page.
38//
39// Already passing when written: it holds the shipped sign-in markup
40// and tokens.css, which this part does not change. Its mutation, run
41// in the gate worktree and reverted: the Apple mark's fill written as
42// a fixed colour in the example, reported as a fill that does not
43// follow the text colour under both schemes.
44func TestTheSocialSignInButtonsStackAndCarryTheirMarks(t *testing.T) {
45 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
46 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
47 defer cancel()
48 const frame = `#google-apple-and-the-rest .ds-view__frame`
49
50 if err := chromedp.Run(ctx,
51 chromedp.EmulateViewport(1500, 1000),
52 chromedp.Navigate(rig.Origin+href(fiesta.ThemeNames()[0]+"/en/signin.html")),
53 chromedp.WaitVisible(frame, chromedp.ByQuery),
54 chromedp.Evaluate(`(() => {
55 const f = document.querySelector(`+"`"+frame+"`"+`);
56 f.loading = "eager"; f.scrollIntoView();
57 return "ok";
58 })()`, new(string)),
59 chromedp.Poll(`(() => {
60 const d = document.querySelector(`+"`"+frame+"`"+`).contentDocument;
61 return !!d && d.readyState === "complete" && !!d.querySelector("[rst-btn]");
62 })()`, nil, chromedp.WithPollingTimeout(30*time.Second)),
63 ); err != nil {
64 t.Fatalf("loading the social sign-in frame: %v", err)
65 }
66
67 type mark struct {
68 Label string
69 FirstIsSVG bool
70 Hidden string
71 Focusable string
72 IconH, FontPx float64
73 CentreOff float64 // icon's vertical centre minus the button's
74 LabelGap float64 // icon's end edge to the label's start
75 BtnGap float64 // the button's own column gap
76 Fill, Colour string // the last path's computed fill, the button's text colour
77 }
78 type reading struct {
79 Buttons int
80 Gap, Sp3 float64
81 Widths []float64
82 Container float64
83 Marks []mark
84 }
85
86 for _, scheme := range []string{"light", "dark"} {
87 var raw string
88 if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => {
89 const d = document.querySelector(`+"`"+frame+"`"+`).contentDocument;
90 d.documentElement.setAttribute("data-theme", `+"`"+scheme+"`"+`);
91 const probe = d.createElement("div");
92 probe.style.blockSize = "var(--rst-sp-3)";
93 d.body.append(probe);
94 const sp3 = probe.getBoundingClientRect().height;
95 probe.remove();
96 const btns = [...d.querySelectorAll("button[rst-btn]")];
97 const r = btns.map(b => b.getBoundingClientRect());
98 const wrap = btns.length ? btns[0].closest("form").parentElement : null;
99 const cs = wrap ? getComputedStyle(wrap) : null;
100 const inner = wrap ? wrap.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) : 0;
101 const marks = btns.map((b, i) => {
102 const svg = b.firstElementChild;
103 const isSVG = !!svg && svg.localName === "svg";
104 const text = [...b.childNodes].find(n => n.nodeType === 3 && n.textContent.trim());
105 const out = { Label: b.textContent.trim(), FirstIsSVG: isSVG, FontPx: parseFloat(getComputedStyle(b).fontSize),
106 BtnGap: parseFloat(getComputedStyle(b).columnGap), Colour: getComputedStyle(b).color };
107 if (!isSVG) return out;
108 const s = svg.getBoundingClientRect();
109 out.Hidden = svg.getAttribute("aria-hidden") || "";
110 out.Focusable = svg.getAttribute("focusable") || "";
111 out.IconH = s.height;
112 out.CentreOff = (s.top + s.height / 2) - (r[i].top + r[i].height / 2);
113 if (text) {
114 const range = d.createRange();
115 range.selectNodeContents(text);
116 out.LabelGap = range.getBoundingClientRect().left - s.right;
117 }
118 const paths = svg.querySelectorAll("path");
119 if (paths.length) out.Fill = getComputedStyle(paths[paths.length - 1]).fill;
120 return out;
121 });
122 return JSON.stringify({
123 Buttons: btns.length,
124 Gap: btns.length > 1 ? r[1].top - r[0].bottom : -1,
125 Sp3: sp3,
126 Widths: r.map(x => x.width),
127 Container: inner,
128 Marks: marks,
129 });
130 })()`, &raw)); err != nil {
131 t.Fatalf("%s: reading the frame: %v", scheme, err)
132 }
133 var got reading
134 if err := json.Unmarshal([]byte(raw), &got); err != nil {
135 t.Fatalf("%s: %v in %s", scheme, err, raw)
136 }
137 if got.Buttons != 2 {
138 t.Fatalf("%s: %d provider buttons, want Google and Apple", scheme, got.Buttons)
139 }
140 // The shipped card spaces its controls by --rst-sp-3; two
141 // buttons with no gap read as one control split in half.
142 if math.Abs(got.Gap-got.Sp3) > 0.5 {
143 t.Errorf("%s: the buttons are %.1fpx apart, want the card's --rst-sp-3 (%.1fpx)", scheme, got.Gap, got.Sp3)
144 }
145 for i, w := range got.Widths {
146 if math.Abs(w-got.Container) > 0.5 {
147 t.Errorf("%s: button %d is %.1fpx wide in a %.1fpx stack; the two only align if both fill it", scheme, i, w, got.Container)
148 }
149 }
150 for i, m := range got.Marks {
151 want := []string{"Continue with Google", "Continue with Apple"}[i]
152 if m.Label != want {
153 t.Errorf("%s: button %d says %q, want %q", scheme, i, m.Label, want)
154 }
155 if !m.FirstIsSVG {
156 t.Errorf("%s: %q has no logo before its label", scheme, m.Label)
157 continue
158 }
159 if m.Hidden != "true" || m.Focusable != "false" {
160 t.Errorf("%s: %q's logo is aria-hidden=%q focusable=%q; the label already names the provider, so the logo must be silent and untabbable",
161 scheme, m.Label, m.Hidden, m.Focusable)
162 }
163 if math.Abs(m.IconH-1.1*m.FontPx) > 1 {
164 t.Errorf("%s: %q's logo is %.1fpx tall on %.1fpx text; want about 1.1em", scheme, m.Label, m.IconH, m.FontPx)
165 }
166 if math.Abs(m.CentreOff) > 1 {
167 t.Errorf("%s: %q's logo sits %.1fpx off the button's vertical centre", scheme, m.Label, m.CentreOff)
168 }
169 if math.Abs(m.LabelGap-m.BtnGap) > 1 {
170 t.Errorf("%s: %q's logo is %.1fpx from its label; rst-btn's own gap is %.1fpx", scheme, m.Label, m.LabelGap, m.BtnGap)
171 }
172 }
173 // Apple's mark is one path in currentColor: a fixed fill would
174 // be black on the dark scheme's dark button, i.e. invisible.
175 if a := got.Marks[1]; a.FirstIsSVG && a.Fill != a.Colour {
176 t.Errorf("%s: the Apple logo is filled %s on a button whose text is %s; it must follow the text colour", scheme, a.Fill, a.Colour)
177 }
178 }
179 rig.Screen("main", "the signin page")
180}
181
182// TestFramePaddingIsAsTheRuleSays: the computed padding of a frame
183// document's body is 1rem on a component's frame and nothing on a
184// shell's (the column's included, whose body no markup rule reaches),
185// the stage's, the modal's and a staged sign-in's, read in the browser
186// rather than off the bytes, since the rule is a selector and a
187// selector is only proven by what it selects.
188func TestFramePaddingIsAsTheRuleSays(t *testing.T) {
189 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
190 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
191 defer cancel()
192 theme := fiesta.ThemeNames()[0]
193 for _, c := range []struct {
194 name, state, want string
195 }{
196 {"button", mustDefault(t, "button"), "16px"},
197 {"layout-stage", mustDefault(t, "layout-stage"), "0px"},
198 {"layout-sidebar", mustDefault(t, "layout-sidebar"), "0px"},
199 {"layout-column", mustDefault(t, "layout-column"), "0px"},
200 {"modal", mustDefault(t, "modal"), "0px"},
201 {"signin", "asking-for-an-address", "0px"},
202 } {
203 file := frameFile(theme, "en", c.name, c.state)
204 var got string
205 mustRun(t, ctx,
206 chromedp.EmulateViewport(1280, 900),
207 chromedp.Navigate(rig.Origin+href(file)),
208 chromedp.WaitReady("body"),
209 chromedp.Evaluate(`getComputedStyle(document.body).paddingTop`, &got),
210 )
211 if got != c.want {
212 t.Errorf("%s: the body's padding-top computes %s, want %s", file, got, c.want)
213 }
214 }
215 rig.Screen("body", "the last frame document")
216}
217
218// slowTheme serves the tree with every theme stylesheet held back, so
219// a scheme set only once the stylesheet arrived would be seen setting
220// it late.
221type slowTheme struct {
222 next http.Handler
223 delay time.Duration
224}
225
226func (h slowTheme) ServeHTTP(w http.ResponseWriter, r *http.Request) {
227 if strings.Contains(r.URL.Path, "/assets/css/themes/") {
228 time.Sleep(h.delay)
229 }
230 h.next.ServeHTTP(w, r)
231}
232
233// firstFrameJS runs before every document's own scripts and records
234// what the root carried when <body> was inserted, which is before any
235// paint and before any deferred script, so only a script in the head
236// can have set it; and again at the first animation frame.
237const firstFrameJS = `(function () {
238 var theme = function () { return document.documentElement.getAttribute("data-theme") || ""; };
239 // On the document, not its root: a new document has no root yet
240 // when this runs.
241 new MutationObserver(function (records, self) {
242 if (!document.body) return;
243 window.__atBody = theme();
244 self.disconnect();
245 }).observe(document, { childList: true, subtree: true });
246 requestAnimationFrame(function () {
247 window.__firstFrame = { theme: theme(), scheme: getComputedStyle(document.documentElement).colorScheme };
248 });
249})();`
250
251// schemeMarkJS reports the scheme menu's buttons and which is marked.
252const schemeMarkJS = `JSON.stringify([...document.querySelectorAll("[rst-scheme] button")].map(b => ({
253 V: b.form && b.form.elements.scheme ? b.form.elements.scheme.value : "",
254 Cur: b.getAttribute("aria-current") || ""
255})))`
256
257// TestADemoOpenedInItsOwnTabKeepsTheScheme: a returning visitor with
258// Dark stored opens a shell demo in a tab of its own, with the theme
259// stylesheet slow to arrive: data-theme="dark" is on <html> when the
260// body is inserted and at the first animation frame, color-scheme
261// computes dark once styled, and the one frame document that holds a
262// scheme menu marks Dark rather than the System the markup ships with.
263// fiesta.js restores a stored scheme only when it is in the head
264// without defer, and these documents load it deferred, so the restore
265// is frame.js's. The way back from the cache is the next drive's.
266func TestADemoOpenedInItsOwnTabKeepsTheScheme(t *testing.T) {
267 rig := harness.New(t, func(string) http.Handler { return slowTheme{newTree(t, nil), 400 * time.Millisecond} })
268 ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute)
269 defer cancel()
270 theme := fiesta.ThemeNames()[0]
271 sidebar := frameFile(theme, "en", "layout-sidebar", mustDefault(t, "layout-sidebar"))
272 menu := frameFile(theme, "en", "scheme-menu", mustDefault(t, "scheme-menu"))
273
274 // The visitor's choice, stored on the origin before the demo opens.
275 mustRun(t, ctx,
276 chromedp.Navigate(rig.Origin+href(theme+"/en/index.html")),
277 chromedp.WaitReady("body"),
278 chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); "ok"`, new(string)),
279 addInit(firstFrameJS),
280 )
281
282 type root struct{ Theme, Scheme string }
283 // readRoot reads the root once the document is whole, its slow
284 // stylesheet included, since color-scheme is the stylesheet's.
285 readRoot := func(where string) root {
286 t.Helper()
287 until(t, ctx, where, `document.readyState === "complete"`)
288 var got root
289 at(t, ctx, `JSON.stringify({Theme: document.documentElement.getAttribute("data-theme") || "", Scheme: getComputedStyle(document.documentElement).colorScheme})`, &got)
290 if got.Theme != "dark" {
291 t.Errorf("%s: <html> carries data-theme=%q, want dark", where, got.Theme)
292 }
293 if got.Scheme != "dark" {
294 t.Errorf("%s: color-scheme computes %q, want dark", where, got.Scheme)
295 }
296 return got
297 }
298 readMark := func(where string) {
299 t.Helper()
300 var buttons []struct{ V, Cur string }
301 at(t, ctx, schemeMarkJS, &buttons)
302 if len(buttons) == 0 {
303 t.Fatalf("%s: no scheme menu button; the mark would be read off nothing", where)
304 }
305 for _, b := range buttons {
306 if (b.V == "dark") != (b.Cur == "true") {
307 t.Errorf("%s: the %s button is aria-current=%q; Dark alone is marked", where, b.V, b.Cur)
308 }
309 }
310 }
311
312 // The shell demo, opened directly.
313 mustRun(t, ctx,
314 chromedp.EmulateViewport(1280, 900),
315 chromedp.Navigate(rig.Origin+href(sidebar)),
316 chromedp.WaitReady("body"),
317 )
318 until(t, ctx, "the sidebar demo", `!!window.__firstFrame`)
319 var atBody string
320 at(t, ctx, `JSON.stringify(window.__atBody || "")`, &atBody)
321 if atBody != "dark" {
322 t.Errorf("when <body> was inserted <html> carried data-theme=%q, want dark: no script in the head set the scheme, so the first paint was in the OS's", atBody)
323 }
324 var first root
325 at(t, ctx, `JSON.stringify(window.__firstFrame)`, &first)
326 if first.Theme != "dark" {
327 t.Errorf("at the first animation frame <html> carried data-theme=%q, want dark", first.Theme)
328 }
329 t.Logf("the sidebar demo: data-theme=%q at <body>, %q at the first animation frame with color-scheme %q", atBody, first.Theme, first.Scheme)
330 readRoot("the sidebar demo once loaded")
331
332 // The one frame document with a scheme menu: the stored scheme is
333 // marked, not the one the markup ships with.
334 mustRun(t, ctx, chromedp.Navigate(rig.Origin+href(menu)), chromedp.WaitReady("body"))
335 readRoot("the scheme menu's document")
336 readMark("the scheme menu's document")
337 rig.Screen("body", "the scheme menu's document in Dark")
338}
339