| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 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. |
| 44 | func 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. |
| 188 | func 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. |
| 221 | type slowTheme struct { |
| 222 | next http.Handler |
| 223 | delay time.Duration |
| 224 | } |
| 225 | |
| 226 | func (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. |
| 237 | const 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. |
| 252 | const 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. |
| 266 | func 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 | |