| 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. |
| 9 | package check |
| 10 | |
| 11 | import ( |
| 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. |
| 29 | func 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. |
| 36 | func 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. |
| 44 | func 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 | |
| 60 | type 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. |
| 86 | func 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. |
| 168 | const 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 #. |
| 200 | func 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. |
| 223 | type 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. |
| 236 | func 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. |
| 308 | func 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 | |