//go:build browser // The sign-in card as the browser lays it out, from rastrillo's // ui/signin_browser_test.go at the seed commit, its pages read through // fixture. The live passkey cases link /static/passkey-signin.mjs, // which rastrillo's server answered with an empty script; this one // does the same, so the door is measured as a browser leaves it when // the script finds no WebAuthn, button still hidden. package check import ( "context" "fmt" "net/http" "strings" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta/internal/harness" ) // signinPage serves the seeded layout page at /: the five cases the // layout drive measures, each rendered into its own container (the // link one-tap with the label's , both passkey doors live and in // Preview, and a brand with every part), with the stylesheets and the // empty passkey script beside it. func signinPage(t *testing.T) http.Handler { t.Helper() return signinServer(t, fixture(t, "signin_browser_test.go", "signinPage", "")) } // signinDoc wraps a body in the bare document rastrillo's signinServer // wrote around every page: the two stylesheets and nothing else. func signinDoc(body string) string { return `` + `signin` + `` + body + `` } // signinServer serves doc as the page at /, with the stylesheets and // an empty passkey script beside it. func signinServer(t *testing.T, doc string) http.Handler { t.Helper() mux := http.NewServeMux() stylesheets(t, mux) // The door's script, empty: the drive measures the door as a // browser leaves it when no WebAuthn is found, button still hidden. mux.HandleFunc("GET /static/passkey-signin.mjs", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/javascript") }) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, doc) }) return mux } type signinLayout struct { // The link one-tap: the space between "as" and the address, and // one space's width in the button's font. LabelGap, SpaceWidth float64 // Per case, the door's children the flex column lays out that have // no height — each still takes a gap. ZeroHeight map[string][]string // The Preview passkey door: the wrapper against its button while // the message is empty, and the space above the message once the // script has written to it. WrapperHeight, ButtonHeight, MessageSpacing float64 // The brand column's type: pitch, name, and the door's heading. Pitch, Name, Heading float64 // The mark's colour against the accent's. Mark, Accent string } // A signin card that looks right in the markup can still be wrong on // screen, three ways, all seen in a screenshot of the gallery: // // - the link one-tap's label split across the button's flex gap, a // double space before the address; // - an empty passkey message, or the wrapper of a hidden button, // taking one of the door's gaps for nothing; // - a brand pitch no larger than the name above it, so a brand with a // pitch reads as two labels rather than a line with a label. func TestTheSigninCardLaysOutAsDesigned(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return signinPage(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() var got signinLayout if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`#case-link [rst-signin]`, chromedp.ByQuery), chromedp.Evaluate(`(function () { const out = { ZeroHeight: {} }; const btn = document.querySelector("#case-link [rst-signin-form] button"); const bdi = btn.querySelector("bdi"); const node = bdi.previousSibling; const end = node.data.trimEnd().length; const r = document.createRange(); r.setStart(node, end - 1); r.setEnd(node, end); out.LabelGap = bdi.getBoundingClientRect().left - r.getBoundingClientRect().right; const ctx = document.createElement("canvas").getContext("2d"); ctx.font = getComputedStyle(bdi).font; out.SpaceWidth = ctx.measureText(" ").width; for (const c of document.querySelectorAll("[id^=case-]")) { const door = c.querySelector("[rst-signin-door]"); out.ZeroHeight[c.id] = [...door.children] .filter(el => getComputedStyle(el).display !== "none" && el.getBoundingClientRect().height === 0) .map(el => el.outerHTML.slice(0, 80)); } const wrap = document.querySelector("#case-passkey-preview [rst-signin-passkey]"); const pk = wrap.querySelector("button"); out.WrapperHeight = wrap.getBoundingClientRect().height; out.ButtonHeight = pk.getBoundingClientRect().height; const msg = wrap.querySelector("[rst-signin-passkey-msg]"); msg.textContent = "No passkey was used."; out.MessageSpacing = msg.getBoundingClientRect().top - pk.getBoundingClientRect().bottom; const size = sel => parseFloat(getComputedStyle(document.querySelector("#case-link " + sel)).fontSize); out.Pitch = size("[rst-signin-pitch]"); out.Name = size("[rst-signin-name]"); out.Heading = size("h1"); out.Mark = getComputedStyle(document.querySelector("#case-link [rst-signin-mark]")).color; const probe = document.createElement("span"); probe.style.color = "var(--rst-accent)"; document.body.appendChild(probe); out.Accent = getComputedStyle(probe).color; return out; })()`, &got), ); err != nil { t.Fatal(err) } if got.SpaceWidth <= 0 { t.Fatalf("measured a space as %.2fpx; the drive is not reading the button's font", got.SpaceWidth) } if got.LabelGap < got.SpaceWidth/2 || got.LabelGap > got.SpaceWidth*1.5 { 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) } for id, zero := range got.ZeroHeight { if len(zero) > 0 { t.Errorf("%s: the door lays out children with no height, each taking a gap: %v", id, zero) } } if d := got.WrapperHeight - got.ButtonHeight; d < -0.5 || d > 0.5 { 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) } if got.MessageSpacing < 4 { t.Errorf("a written passkey message sits %.2fpx under its button; it needs space once it has something to say", got.MessageSpacing) } if got.Pitch <= got.Heading || got.Pitch <= got.Name { 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) } if got.Mark != got.Accent { t.Errorf("the mark's colour is %s, the accent %s; a currentColor mark takes the theme's accent", got.Mark, got.Accent) } } // fitJS reports, per case container, every button and form control in // the card whose box leaves the card's content box by more than half a // pixel on either side. Hidden controls have no box and are skipped. const fitJS = `(function () { const out = {}; for (const c of document.querySelectorAll("[id^=case-]")) { const card = c.querySelector("[rst-signin]"); const cs = getComputedStyle(card); const r = card.getBoundingClientRect(); const left = r.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft); const right = r.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight); const bad = []; for (const el of card.querySelectorAll("[rst-btn], input:not([type=hidden]), select, textarea")) { const b = el.getBoundingClientRect(); if (b.width === 0 && b.height === 0) continue; if (b.left < left - 0.5 || b.right > right + 0.5) { bad.push(el.outerHTML.slice(0, 60) + " spans " + b.left.toFixed(1) + ".." + b.right.toFixed(1) + " in " + left.toFixed(1) + ".." + right.toFixed(1)); } } if (bad.length) out[c.id + " (" + c.title + ")"] = bad; } return out; })()` // A full-width control drawn wider than its card pokes out past the // card's edge on a phone. It was seen on Continue's link: an is // content-box, unlike a