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 sign-in card as the browser lays it out, from rastrillo's
4// ui/signin_browser_test.go at the seed commit, its pages read through
5// fixture. The live passkey cases link /static/passkey-signin.mjs,
6// which rastrillo's server answered with an empty script; this one
7// does the same, so the door is measured as a browser leaves it when
8// the script finds no WebAuthn, button still hidden.
9package check
10
11import (
12 "context"
13 "fmt"
14 "net/http"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/chromedp/chromedp"
20
21 "amadan.net/rastrillo/fiesta/internal/harness"
22)
23
24// signinPage serves the seeded layout page at /: the five cases the
25// layout drive measures, each rendered into its own container (the
26// link one-tap with the label's <bdi>, both passkey doors live and in
27// Preview, and a brand with every part), with the stylesheets and the
28// empty passkey script beside it.
29func signinPage(t *testing.T) http.Handler {
30 t.Helper()
31 return signinServer(t, fixture(t, "signin_browser_test.go", "signinPage", ""))
32}
33
34// signinDoc wraps a body in the bare document rastrillo's signinServer
35// wrote around every page: the two stylesheets and nothing else.
36func signinDoc(body string) string {
37 return `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
38 `<title>signin</title><link rel="stylesheet" href="/tokens.css">` +
39 `<link rel="stylesheet" href="/theme.css"></head><body>` + body + `</body></html>`
40}
41
42// signinServer serves doc as the page at /, with the stylesheets and
43// an empty passkey script beside it.
44func signinServer(t *testing.T, doc string) http.Handler {
45 t.Helper()
46 mux := http.NewServeMux()
47 stylesheets(t, mux)
48 // The door's script, empty: the drive measures the door as a
49 // browser leaves it when no WebAuthn is found, button still hidden.
50 mux.HandleFunc("GET /static/passkey-signin.mjs", func(w http.ResponseWriter, r *http.Request) {
51 w.Header().Set("Content-Type", "text/javascript")
52 })
53 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
54 w.Header().Set("Content-Type", "text/html; charset=utf-8")
55 fmt.Fprint(w, doc)
56 })
57 return mux
58}
59
60type signinLayout struct {
61 // The link one-tap: the space between "as" and the address, and
62 // one space's width in the button's font.
63 LabelGap, SpaceWidth float64
64 // Per case, the door's children the flex column lays out that have
65 // no height — each still takes a gap.
66 ZeroHeight map[string][]string
67 // The Preview passkey door: the wrapper against its button while
68 // the message is empty, and the space above the message once the
69 // script has written to it.
70 WrapperHeight, ButtonHeight, MessageSpacing float64
71 // The brand column's type: pitch, name, and the door's heading.
72 Pitch, Name, Heading float64
73 // The mark's colour against the accent's.
74 Mark, Accent string
75}
76
77// A signin card that looks right in the markup can still be wrong on
78// screen, three ways, all seen in a screenshot of the gallery:
79//
80// - the link one-tap's label split across the button's flex gap, a
81// double space before the address;
82// - an empty passkey message, or the wrapper of a hidden button,
83// taking one of the door's gaps for nothing;
84// - a brand pitch no larger than the name above it, so a brand with a
85// pitch reads as two labels rather than a line with a label.
86func TestTheSigninCardLaysOutAsDesigned(t *testing.T) {
87 rig := harness.New(t, func(string) http.Handler { return signinPage(t) })
88 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
89 defer cancel()
90
91 var got signinLayout
92 if err := chromedp.Run(ctx,
93 chromedp.EmulateViewport(1280, 900),
94 chromedp.Navigate(rig.Origin+"/"),
95 chromedp.WaitVisible(`#case-link [rst-signin]`, chromedp.ByQuery),
96 chromedp.Evaluate(`(function () {
97 const out = { ZeroHeight: {} };
98 const btn = document.querySelector("#case-link [rst-signin-form] button");
99 const bdi = btn.querySelector("bdi");
100 const node = bdi.previousSibling;
101 const end = node.data.trimEnd().length;
102 const r = document.createRange();
103 r.setStart(node, end - 1);
104 r.setEnd(node, end);
105 out.LabelGap = bdi.getBoundingClientRect().left - r.getBoundingClientRect().right;
106 const ctx = document.createElement("canvas").getContext("2d");
107 ctx.font = getComputedStyle(bdi).font;
108 out.SpaceWidth = ctx.measureText(" ").width;
109
110 for (const c of document.querySelectorAll("[id^=case-]")) {
111 const door = c.querySelector("[rst-signin-door]");
112 out.ZeroHeight[c.id] = [...door.children]
113 .filter(el => getComputedStyle(el).display !== "none" && el.getBoundingClientRect().height === 0)
114 .map(el => el.outerHTML.slice(0, 80));
115 }
116
117 const wrap = document.querySelector("#case-passkey-preview [rst-signin-passkey]");
118 const pk = wrap.querySelector("button");
119 out.WrapperHeight = wrap.getBoundingClientRect().height;
120 out.ButtonHeight = pk.getBoundingClientRect().height;
121 const msg = wrap.querySelector("[rst-signin-passkey-msg]");
122 msg.textContent = "No passkey was used.";
123 out.MessageSpacing = msg.getBoundingClientRect().top - pk.getBoundingClientRect().bottom;
124
125 const size = sel => parseFloat(getComputedStyle(document.querySelector("#case-link " + sel)).fontSize);
126 out.Pitch = size("[rst-signin-pitch]");
127 out.Name = size("[rst-signin-name]");
128 out.Heading = size("h1");
129 out.Mark = getComputedStyle(document.querySelector("#case-link [rst-signin-mark]")).color;
130 const probe = document.createElement("span");
131 probe.style.color = "var(--rst-accent)";
132 document.body.appendChild(probe);
133 out.Accent = getComputedStyle(probe).color;
134 return out;
135})()`, &got),
136 ); err != nil {
137 t.Fatal(err)
138 }
139
140 if got.SpaceWidth <= 0 {
141 t.Fatalf("measured a space as %.2fpx; the drive is not reading the button's font", got.SpaceWidth)
142 }
143 if got.LabelGap < got.SpaceWidth/2 || got.LabelGap > got.SpaceWidth*1.5 {
144 t.Errorf("the link one-tap has %.2fpx between \"as\" and the address, and a space is %.2fpx; want one space", got.LabelGap, got.SpaceWidth)
145 }
146 for id, zero := range got.ZeroHeight {
147 if len(zero) > 0 {
148 t.Errorf("%s: the door lays out children with no height, each taking a gap: %v", id, zero)
149 }
150 }
151 if d := got.WrapperHeight - got.ButtonHeight; d < -0.5 || d > 0.5 {
152 t.Errorf("with its message empty the passkey pair is %.2fpx tall and its button %.2fpx; the empty message is taking space", got.WrapperHeight, got.ButtonHeight)
153 }
154 if got.MessageSpacing < 4 {
155 t.Errorf("a written passkey message sits %.2fpx under its button; it needs space once it has something to say", got.MessageSpacing)
156 }
157 if got.Pitch <= got.Heading || got.Pitch <= got.Name {
158 t.Errorf("the pitch is %.1fpx, the door's heading %.1fpx and the name %.1fpx; the pitch is the column's dominant line", got.Pitch, got.Heading, got.Name)
159 }
160 if got.Mark != got.Accent {
161 t.Errorf("the mark's colour is %s, the accent %s; a currentColor mark takes the theme's accent", got.Mark, got.Accent)
162 }
163}
164
165// fitJS reports, per case container, every button and form control in
166// the card whose box leaves the card's content box by more than half a
167// pixel on either side. Hidden controls have no box and are skipped.
168const fitJS = `(function () {
169 const out = {};
170 for (const c of document.querySelectorAll("[id^=case-]")) {
171 const card = c.querySelector("[rst-signin]");
172 const cs = getComputedStyle(card);
173 const r = card.getBoundingClientRect();
174 const left = r.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft);
175 const right = r.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight);
176 const bad = [];
177 for (const el of card.querySelectorAll("[rst-btn], input:not([type=hidden]), select, textarea")) {
178 const b = el.getBoundingClientRect();
179 if (b.width === 0 && b.height === 0) continue;
180 if (b.left < left - 0.5 || b.right > right + 0.5) {
181 bad.push(el.outerHTML.slice(0, 60) + " spans " + b.left.toFixed(1) + ".." + b.right.toFixed(1) +
182 " in " + left.toFixed(1) + ".." + right.toFixed(1));
183 }
184 }
185 if (bad.length) out[c.id + " (" + c.title + ")"] = bad;
186 }
187 return out;
188})()`
189
190// A full-width control drawn wider than its card pokes out past the
191// card's edge on a phone. It was seen on Continue's link: an <a> is
192// content-box, unlike a <button>, so width: 100% plus its padding and
193// border is wider than the column it fills. Every state, live and in
194// Preview (where the passkey button shows), at the two phone widths
195// the gallery and WCAG's reflow measure. The seeded page holds every
196// state as rastrillo built it, in a <div id="case-<n>-<preview>"> each;
197// Continue's live page, which refreshes to keymail at once and would
198// take the drive with it, is absent, and its Preview is the same card
199// with the link pointing at #.
200func TestSigninControlsStayInsideTheCardOnAPhone(t *testing.T) {
201 body := fixture(t, "signin_browser_test.go", "TestSigninControlsStayInsideTheCardOnAPhone", "")
202 rig := harness.New(t, func(string) http.Handler { return signinServer(t, signinDoc(body)) })
203 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
204 defer cancel()
205
206 for _, width := range []int64{320, 390} {
207 var got map[string][]string
208 if err := chromedp.Run(ctx,
209 chromedp.EmulateViewport(width, 900),
210 chromedp.Navigate(rig.Origin+"/"),
211 chromedp.WaitVisible(`#case-0-false [rst-signin]`, chromedp.ByQuery),
212 chromedp.Evaluate(fitJS, &got),
213 ); err != nil {
214 t.Fatal(err)
215 }
216 for id, bad := range got {
217 t.Errorf("%dpx, %s: controls outside the card's content box:\n\t%s", width, id, strings.Join(bad, "\n\t"))
218 }
219 }
220}
221
222// orState is one door's divider as the browser draws it.
223type orState struct {
224 Shown bool
225 // The rule on each side of the word, and the word's and the rules'
226 // colours against the theme's muted text and hairline.
227 Before, After float64
228 Colour, Muted, Rule, Line string
229}
230
231// The divider says "or" between two ways in, so it shows exactly when
232// the passkey button does: hidden while the live door's script has not
233// revealed the button (here, an empty script that never will), shown in
234// Preview, and shown the moment a script unhides the button — the
235// wrapper's :has() rule, not a second script, is what brings it back.
236func TestTheOrDividerShowsOnlyWithThePasskeyButton(t *testing.T) {
237 rig := harness.New(t, func(string) http.Handler { return signinPage(t) })
238 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
239 defer cancel()
240
241 const read = `(function () {
242 const probe = document.createElement("span");
243 probe.style.color = "var(--rst-text-muted)";
244 document.body.appendChild(probe);
245 const muted = getComputedStyle(probe).color;
246 probe.style.color = "var(--rst-line)";
247 const line = getComputedStyle(probe).color;
248 probe.remove();
249 const out = {};
250 for (const id of ["ask-live", "ask-preview", "passkey-live", "passkey-preview"]) {
251 const or = document.querySelector("#case-" + id + " [rst-signin-or]");
252 if (!or) { out[id] = null; continue; }
253 const r = or.getBoundingClientRect();
254 out[id] = {
255 Shown: getComputedStyle(or).display !== "none" && r.height > 0,
256 Before: parseFloat(getComputedStyle(or, "::before").width) || 0,
257 After: parseFloat(getComputedStyle(or, "::after").width) || 0,
258 Colour: getComputedStyle(or).color, Muted: muted,
259 Rule: getComputedStyle(or, "::before").borderTopColor + " " + getComputedStyle(or, "::after").borderTopColor, Line: line,
260 };
261 }
262 return out;
263})()`
264 var before, after map[string]*orState
265 if err := chromedp.Run(ctx,
266 chromedp.EmulateViewport(1280, 900),
267 chromedp.Navigate(rig.Origin+"/"),
268 chromedp.WaitVisible(`#case-ask-preview [rst-signin]`, chromedp.ByQuery),
269 chromedp.Evaluate(read, &before),
270 // What the door's script does once it has found a working
271 // WebAuthn: it unhides the button, and nothing else.
272 chromedp.Evaluate(`document.querySelectorAll("[data-rst-passkey]").forEach(b => { b.hidden = false; }); "ok"`, new(string)),
273 chromedp.Evaluate(read, &after),
274 ); err != nil {
275 t.Fatal(err)
276 }
277 for _, id := range []string{"ask-live", "ask-preview", "passkey-live", "passkey-preview"} {
278 b, a := before[id], after[id]
279 if b == nil {
280 t.Errorf("%s: no divider on the page", id)
281 continue
282 }
283 live := strings.HasSuffix(id, "-live")
284 if b.Shown == live {
285 t.Errorf("%s: the divider shown is %v with the passkey button hidden %v; it goes with the button", id, b.Shown, live)
286 }
287 if !a.Shown {
288 t.Errorf("%s: the passkey button is showing and the divider is not", id)
289 continue
290 }
291 if a.Before < 8 || a.After < 8 {
292 t.Errorf("%s: the divider's rules are %.1fpx and %.1fpx; want a line on each side of the word", id, a.Before, a.After)
293 }
294 if a.Colour != a.Muted {
295 t.Errorf("%s: the divider's text is %s, the muted text colour %s", id, a.Colour, a.Muted)
296 }
297 if a.Rule != a.Line+" "+a.Line {
298 t.Errorf("%s: the divider's rules are %s, the hairline %s", id, a.Rule, a.Line)
299 }
300 }
301}
302
303// The same overflow as Continue's, anywhere in an app: rst-btn's block
304// is width: 100%, and an <a> — unlike a <button> — is content-box by
305// the browser's own stylesheet, so a link-button that fills its column
306// came out its padding and border wider than it. Every size, since the
307// padding, and so the overshoot, differs per size.
308func TestABlockLinkButtonStaysInsideItsContainer(t *testing.T) {
309 var body strings.Builder
310 body.WriteString(`<div id="box" style="inline-size: 320px">`)
311 for _, v := range []string{"primary block", "primary block sm", "primary block lg", "ghost block"} {
312 fmt.Fprintf(&body, `<p><a rst-btn=%q href="#">Continue to Keymail</a></p>`, v)
313 }
314 body.WriteString(`</div>`)
315 rig := harness.New(t, func(string) http.Handler { return signinServer(t, signinDoc(body.String())) })
316 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
317 defer cancel()
318
319 var bad []string
320 if err := chromedp.Run(ctx,
321 chromedp.EmulateViewport(390, 900),
322 chromedp.Navigate(rig.Origin+"/"),
323 chromedp.WaitVisible(`#box a`, chromedp.ByQuery),
324 chromedp.Evaluate(`(function () {
325 const box = document.getElementById("box").getBoundingClientRect();
326 return [...document.querySelectorAll("#box a")].map(a => [a, a.getBoundingClientRect()])
327 .filter(([, r]) => r.left < box.left - 0.5 || r.right > box.right + 0.5)
328 .map(([a, r]) => a.getAttribute("rst-btn") + " spans " + r.left.toFixed(1) + ".." + r.right.toFixed(1) +
329 " in " + box.left.toFixed(1) + ".." + box.right.toFixed(1));
330})()`, &bad),
331 ); err != nil {
332 t.Fatal(err)
333 }
334 for _, b := range bad {
335 t.Errorf("an <a rst-btn> block overflows its 320px container: %s", b)
336 }
337}
338