| 1 | //go:build browser |
| 2 | |
| 3 | // The scheme switch in a browser: a choice that a standalone page |
| 4 | // remembers across a reload, a restore that lands before the body's |
| 5 | // own script runs, and a server-owned page that reads nothing a |
| 6 | // standalone page on the same origin stored. |
| 7 | package check |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "fmt" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/chromedp/cdproto/emulation" |
| 17 | "github.com/chromedp/cdproto/input" |
| 18 | "github.com/chromedp/chromedp" |
| 19 | |
| 20 | "amadan.net/rastrillo/fiesta" |
| 21 | "amadan.net/rastrillo/fiesta/example" |
| 22 | "amadan.net/rastrillo/fiesta/internal/axe" |
| 23 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 24 | ) |
| 25 | |
| 26 | // schemeMenu is the scheme-menu partial's markup at the seed commit, |
| 27 | // by hand: a dropdown of three one-field forms, System current, no |
| 28 | // script. The scheme-menu example's standalone state carries the same |
| 29 | // shape. The forms name no action, so a submit fiesta.js failed to |
| 30 | // take would POST to the page itself and the fixture server would |
| 31 | // answer 405, which the harness reports. |
| 32 | const schemeMenu = `<details rst-dropdown rst-scheme name="rst-menus"> |
| 33 | <summary>Colour scheme</summary> |
| 34 | <div rst-dropdown-menu role="group" aria-label="Colour scheme"> |
| 35 | <form method="post"><input type="hidden" name="scheme" value="light"><button type="submit">Light</button></form> |
| 36 | <form method="post"><input type="hidden" name="scheme" value="dark"><button type="submit">Dark</button></form> |
| 37 | <form method="post"><input type="hidden" name="scheme" value="system"><button type="submit" aria-current="true">System</button></form> |
| 38 | </div> |
| 39 | </details>` |
| 40 | |
| 41 | // schemeDoc is a page with the stylesheet and a theme, the menu, and |
| 42 | // the scripts head asks for. store puts data-rst-scheme-store="local" |
| 43 | // on <html>, where fiesta.js reads it before anything else runs. The |
| 44 | // body's own script also counts the rst-scheme events a listener on |
| 45 | // the document hears, which is how an app's cookie writer listens. |
| 46 | func schemeDoc(store bool, head string) string { |
| 47 | attr := "" |
| 48 | if store { |
| 49 | attr = ` data-rst-scheme-store="local"` |
| 50 | } |
| 51 | return `<!doctype html><html lang="en" dir="ltr"` + attr + `><head><meta charset="utf-8">` + |
| 52 | `<meta name="viewport" content="width=device-width"><title>scheme</title>` + |
| 53 | `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme.css">` + head + |
| 54 | `</head><body><div rst-page>` + schemeMenu + `</div>` + |
| 55 | `<script>window.__early = document.documentElement.getAttribute("data-theme") || "";` + |
| 56 | `document.addEventListener("rst-scheme", () => { window.__heard = (window.__heard || 0) + 1; });</script></body></html>` |
| 57 | } |
| 58 | |
| 59 | // schemePages is what driveSchemeRemember and the drives beside it |
| 60 | // navigate: / is a standalone page, /server the same page without the |
| 61 | // attribute, and /early a standalone page with fiesta.js in <head> |
| 62 | // without defer. Every page records data-theme as the body's own |
| 63 | // script sees it, in window.__early. |
| 64 | func schemePages() map[string]string { |
| 65 | deferred := `<script defer src="/fiesta.js"></script>` |
| 66 | return map[string]string{ |
| 67 | "/": schemeDoc(true, deferred), |
| 68 | "/server": schemeDoc(false, deferred), |
| 69 | "/early": schemeDoc(true, `<script src="/fiesta.js"></script>`), |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // schemeState is what the page says about itself after each step. |
| 74 | type schemeState struct { |
| 75 | Theme string // data-theme on <html>, "" for none |
| 76 | Stored string // localStorage["fiesta-scheme"], "" for none |
| 77 | Cookie string // document.cookie |
| 78 | Current string // the scheme whose button is aria-current |
| 79 | Open bool // the menu is still open |
| 80 | Mark bool // window.__mark survived: no navigation happened |
| 81 | Early string // data-theme as the body's inline script saw it |
| 82 | Heard int // rst-scheme events a document listener heard |
| 83 | } |
| 84 | |
| 85 | const schemeStateJS = `(() => { |
| 86 | const m = document.querySelector("[rst-scheme]"); |
| 87 | const cur = m.querySelector("button[aria-current]"); |
| 88 | let stored = ""; |
| 89 | try { stored = localStorage.getItem("fiesta-scheme") || ""; } catch (e) {} |
| 90 | return JSON.stringify({ |
| 91 | Theme: document.documentElement.getAttribute("data-theme") || "", |
| 92 | Stored: stored, |
| 93 | Cookie: document.cookie, |
| 94 | Current: cur ? cur.form.elements.scheme.value : "", |
| 95 | Open: m.open, |
| 96 | Mark: window.__mark === 1, |
| 97 | Early: window.__early, |
| 98 | Heard: window.__heard || 0 |
| 99 | }); |
| 100 | })()` |
| 101 | |
| 102 | // chooseScheme opens the menu and presses one scheme's button, then |
| 103 | // waits for the menu to close, which the submit handler does in place. |
| 104 | // The mark it leaves on the window says afterwards whether the page |
| 105 | // navigated. |
| 106 | func chooseScheme(t *testing.T, ctx context.Context, scheme string) { |
| 107 | t.Helper() |
| 108 | btn := fmt.Sprintf(`[rst-scheme] input[value=%q] ~ button`, scheme) |
| 109 | mustRun(t, ctx, |
| 110 | chromedp.Evaluate(`window.__mark = 1; true`, nil), |
| 111 | chromedp.Click(`[rst-scheme] > summary`, chromedp.ByQuery), |
| 112 | chromedp.WaitVisible(btn, chromedp.ByQuery), |
| 113 | chromedp.Click(btn, chromedp.ByQuery), |
| 114 | ) |
| 115 | settleUntil(t, ctx, `!document.querySelector("[rst-scheme]").open`) |
| 116 | } |
| 117 | |
| 118 | // driveSchemeRemember is the body of TestAStandaloneSchemeSurvivesAReload, |
| 119 | // against any rig that serves schemePages (internal/build's drives run it |
| 120 | // with the release's scripts). Dark is chosen in place: the attribute |
| 121 | // changes, the key is stored, no cookie is written, the menu closes and |
| 122 | // nothing navigates. A reload paints Dark again and marks its button, |
| 123 | // which can only have come from storage. System undoes all of it, reload |
| 124 | // included. |
| 125 | func driveSchemeRemember(t *testing.T, rig *harness.Rig) { |
| 126 | t.Helper() |
| 127 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 128 | defer cancel() |
| 129 | |
| 130 | var got schemeState |
| 131 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| 132 | at(t, ctx, schemeStateJS, &got) |
| 133 | if got.Theme != "" || got.Current != "system" || got.Stored != "" { |
| 134 | t.Fatalf("first visit: %+v", got) |
| 135 | } |
| 136 | |
| 137 | chooseScheme(t, ctx, "dark") |
| 138 | at(t, ctx, schemeStateJS, &got) |
| 139 | if got.Theme != "dark" || got.Current != "dark" || got.Stored != "dark" || got.Cookie != "" || got.Open || !got.Mark { |
| 140 | t.Fatalf("after choosing Dark in place: %+v", got) |
| 141 | } |
| 142 | if got.Heard != 1 { |
| 143 | t.Fatalf("a listener on the document heard %d rst-scheme events after Dark, want 1; the event is dispatched on the form and must bubble", got.Heard) |
| 144 | } |
| 145 | |
| 146 | mustRun(t, ctx, chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| 147 | at(t, ctx, schemeStateJS, &got) |
| 148 | if got.Mark { |
| 149 | t.Fatal("the reload did not happen: the mark survived") |
| 150 | } |
| 151 | if got.Theme != "dark" || got.Current != "dark" { |
| 152 | t.Fatalf("after a reload the stored scheme was not restored: %+v", got) |
| 153 | } |
| 154 | |
| 155 | chooseScheme(t, ctx, "system") |
| 156 | at(t, ctx, schemeStateJS, &got) |
| 157 | if got.Theme != "" || got.Current != "system" || got.Stored != "" || !got.Mark { |
| 158 | t.Fatalf("after choosing System in place: %+v", got) |
| 159 | } |
| 160 | mustRun(t, ctx, chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| 161 | at(t, ctx, schemeStateJS, &got) |
| 162 | if got.Theme != "" || got.Current != "system" || got.Stored != "" { |
| 163 | t.Fatalf("after a reload on System: %+v", got) |
| 164 | } |
| 165 | rig.Screen("body", "the scheme menu after Dark, System and two reloads") |
| 166 | } |
| 167 | |
| 168 | func TestAStandaloneSchemeSurvivesAReload(t *testing.T) { |
| 169 | driveSchemeRemember(t, sizingRig(t, false, schemePages())) |
| 170 | } |
| 171 | |
| 172 | // With fiesta.js in <head> without defer, the restore runs before the |
| 173 | // body: the inline script at the end of the body already sees |
| 174 | // data-theme. Before anything is stored the same page records |
| 175 | // nothing, which is the control. |
| 176 | func TestTheStoredSchemeIsSetBeforeTheBodyRuns(t *testing.T) { |
| 177 | rig := sizingRig(t, false, schemePages()) |
| 178 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 179 | defer cancel() |
| 180 | |
| 181 | var got schemeState |
| 182 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/early"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| 183 | at(t, ctx, schemeStateJS, &got) |
| 184 | if got.Early != "" || got.Theme != "" { |
| 185 | t.Fatalf("with nothing stored: %+v", got) |
| 186 | } |
| 187 | mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil), |
| 188 | chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| 189 | at(t, ctx, schemeStateJS, &got) |
| 190 | if got.Early != "dark" { |
| 191 | t.Errorf("the body's script saw data-theme %q; the restore did not run before the body", got.Early) |
| 192 | } |
| 193 | if got.Theme != "dark" || got.Current != "dark" { |
| 194 | t.Errorf("after the reload: %+v", got) |
| 195 | } |
| 196 | rig.Screen("body", "the early page with Dark stored") |
| 197 | } |
| 198 | |
| 199 | // A standalone page and a server-owned page on one origin share a |
| 200 | // localStorage. The server-owned page, which carries no attribute, |
| 201 | // neither reads what the other stored (it loads without data-theme and |
| 202 | // stays light) nor writes over it when its own switch is used. |
| 203 | func TestAServerOwnedSchemeSurvivesAStandalonePage(t *testing.T) { |
| 204 | rig := sizingRig(t, false, schemePages()) |
| 205 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 206 | defer cancel() |
| 207 | |
| 208 | var got schemeState |
| 209 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| 210 | chooseScheme(t, ctx, "dark") |
| 211 | at(t, ctx, schemeStateJS, &got) |
| 212 | if got.Stored != "dark" { |
| 213 | t.Fatalf("the standalone page did not store Dark: %+v", got) |
| 214 | } |
| 215 | |
| 216 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/server"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| 217 | at(t, ctx, schemeStateJS, &got) |
| 218 | if got.Theme != "" || got.Early != "" || got.Current != "system" { |
| 219 | t.Fatalf("the server-owned page took the stored scheme: %+v", got) |
| 220 | } |
| 221 | if got.Stored != "dark" { |
| 222 | t.Fatalf("the server-owned page touched storage on load: %+v", got) |
| 223 | } |
| 224 | |
| 225 | chooseScheme(t, ctx, "light") |
| 226 | at(t, ctx, schemeStateJS, &got) |
| 227 | if got.Theme != "light" || got.Current != "light" || !got.Mark { |
| 228 | t.Fatalf("after choosing Light on the server-owned page: %+v", got) |
| 229 | } |
| 230 | if got.Stored != "dark" { |
| 231 | t.Errorf("the server-owned page wrote storage: fiesta-scheme is %q, want the standalone page's dark", got.Stored) |
| 232 | } |
| 233 | rig.Screen("body", "the server-owned page after Light") |
| 234 | } |
| 235 | |
| 236 | // ── The scheme menu under axe ──────────────────────────────────────── |
| 237 | |
| 238 | // schemeMenuPages serves the scheme-menu example's standalone state |
| 239 | // whole, through example.Document, so the <html> that carries |
| 240 | // data-rst-scheme-store="local" comes along with the menu. Its |
| 241 | // relative asset links are rewritten to the flat names the fixture |
| 242 | // server answers (parityLinks), and nothing else in the document is |
| 243 | // touched. It takes over from rastrillo's server-backed schemeApp, |
| 244 | // which stays in rastrillo with the tests that need a server. |
| 245 | func schemeMenuPages(t *testing.T) map[string]string { |
| 246 | t.Helper() |
| 247 | state, ok := manifest(t).Default("scheme-menu") |
| 248 | if !ok || state == "" { |
| 249 | t.Fatal("examples.json names no default state for scheme-menu") |
| 250 | } |
| 251 | doc, err := example.Document(fiesta.FS(), "scheme-menu", state) |
| 252 | if err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | if !strings.Contains(doc, `data-rst-scheme-store="local"`) { |
| 256 | t.Fatalf("scheme-menu/%s is not the standalone state: its root carries no data-rst-scheme-store", state) |
| 257 | } |
| 258 | return map[string]string{"/": parityLinks(doc)} |
| 259 | } |
| 260 | |
| 261 | // axeClean scans the whole page and reports every violation, each |
| 262 | // with the nodes it names; what axe could not decide is logged, so |
| 263 | // the seam between the scanned half of the standard and the reviewed |
| 264 | // half is never silent. |
| 265 | func axeClean(t *testing.T, ctx context.Context, where string) { |
| 266 | t.Helper() |
| 267 | res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| 268 | if err != nil { |
| 269 | t.Fatalf("%s: axe: %v", where, err) |
| 270 | } |
| 271 | if len(res.Incomplete) > 0 { |
| 272 | t.Logf("%s: needs review, axe could not decide: %v", where, res.Incomplete) |
| 273 | } |
| 274 | for _, v := range res.Violations { |
| 275 | t.Errorf("%s: %s [%s] %s: %s", where, v.ID, v.Impact, v.Help, strings.Join(v.Nodes, "; ")) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // TestTheSchemeMenuPassesAxeInBothSchemes: the standalone scheme menu, |
| 280 | // served with fiesta.js as the example links it, passes axe with the |
| 281 | // menu open before a choice and after Dark is chosen in place. The |
| 282 | // choice sets data-theme="dark" on the root and navigates nowhere (the |
| 283 | // mark chooseScheme leaves on the window survives it). The root is |
| 284 | // read first: without data-rst-scheme-store="local" this would be a |
| 285 | // server-owned page, and the drive could not tell the two apart. The |
| 286 | // first scan is light because a light preference is emulated and |
| 287 | // checked, not because the browser happens to default to one. |
| 288 | func TestTheSchemeMenuPassesAxeInBothSchemes(t *testing.T) { |
| 289 | rig := sizingRig(t, false, schemeMenuPages(t)) |
| 290 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 291 | defer cancel() |
| 292 | light := emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}) |
| 293 | mustRun(t, ctx, light, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| 294 | var dark bool |
| 295 | mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(prefers-color-scheme: dark)").matches`, &dark)) |
| 296 | if dark { |
| 297 | t.Fatal("the page prefers dark, so the light scan would scan dark") |
| 298 | } |
| 299 | |
| 300 | var store string |
| 301 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.getAttribute("data-rst-scheme-store") || ""`, &store)) |
| 302 | if store != "local" { |
| 303 | t.Fatalf("the served document's root carries data-rst-scheme-store=%q, want local: this is not the standalone page", store) |
| 304 | } |
| 305 | var got schemeState |
| 306 | at(t, ctx, schemeStateJS, &got) |
| 307 | if got.Theme != "" || got.Stored != "" { |
| 308 | t.Fatalf("first visit: %+v", got) |
| 309 | } |
| 310 | |
| 311 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = true`, nil), chromedp.WaitVisible(`[rst-scheme] button`, chromedp.ByQuery)) |
| 312 | axeClean(t, ctx, "light, menu open") |
| 313 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = false`, nil)) |
| 314 | |
| 315 | chooseScheme(t, ctx, "dark") |
| 316 | at(t, ctx, schemeStateJS, &got) |
| 317 | if got.Theme != "dark" || got.Current != "dark" || got.Open || !got.Mark { |
| 318 | t.Fatalf("after choosing Dark in place: %+v", got) |
| 319 | } |
| 320 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = true`, nil), chromedp.WaitVisible(`[rst-scheme] button`, chromedp.ByQuery)) |
| 321 | axeClean(t, ctx, "dark, menu open") |
| 322 | rig.Screen("body", "the standalone scheme menu open in dark") |
| 323 | } |
| 324 | |
| 325 | // ── A scheme flip lands (ui/scheme_browser_test.go) ────────────────── |
| 326 | |
| 327 | // schemeFlipBody is the page a scheme flip is read on: a sidebar's |
| 328 | // current nav item and a linked media tile, the two things whose |
| 329 | // background fades between schemes (0.2s and 0.25s, for the press |
| 330 | // and the hover) while their text changes at once, and the switch's |
| 331 | // own forms as an app renders them. |
| 332 | const schemeFlipBody = `<div rst-shell-sidebar><nav rst-shell-rail><div rst-shell-nav><a href="#x">Overview</a><a href="#y" aria-current="page" id="nav-cur">Requests</a></div></nav></div> |
| 333 | <div rst-list><div rst-media><div rst-media-tile id="tile"><div rst-media-frame><img src="/images/a.svg" alt=""></div><div rst-media-caption><div rst-media-name><a href="#t">Picture</a></div></div></div></div></div> |
| 334 | <details rst-dropdown rst-scheme><summary>Theme</summary><div rst-dropdown-menu> |
| 335 | <form method="post" action="/_theme"><input type="hidden" name="scheme" value="light"><button>Light</button></form> |
| 336 | <form method="post" action="/_theme"><input type="hidden" name="scheme" value="dark"><button>Dark</button></form> |
| 337 | <form method="post" action="/_theme"><input type="hidden" name="scheme" value="system"><button>System</button></form> |
| 338 | </div></details>` |
| 339 | |
| 340 | // schemeFlipState is the two backgrounds and the scheme as read on |
| 341 | // one frame. |
| 342 | type schemeFlipState struct { |
| 343 | Theme string // data-theme on <html>, "" for none |
| 344 | Nav string // the current nav item's computed background |
| 345 | Tile string // the linked media tile's computed background |
| 346 | } |
| 347 | |
| 348 | // schemeFlipReadJS reads the state on the next animation frame. On |
| 349 | // the next frame rather than now: a transition's first frame is its |
| 350 | // start value, which is the same number the test would read without |
| 351 | // a flip at all. |
| 352 | const schemeFlipReadJS = `new Promise(r => requestAnimationFrame(() => r(JSON.stringify({ |
| 353 | Theme: document.documentElement.getAttribute("data-theme") || "", |
| 354 | Nav: getComputedStyle(document.getElementById("nav-cur")).backgroundColor, |
| 355 | Tile: getComputedStyle(document.getElementById("tile")).backgroundColor |
| 356 | }))))` |
| 357 | |
| 358 | // schemeFlipLandedJS waits out every finite animation, then reads the |
| 359 | // same state: the value a reader sees once the page has settled. |
| 360 | const schemeFlipLandedJS = `(async () => { |
| 361 | await Promise.all(document.getAnimations().filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity).map(a => a.finished.catch(() => null))); |
| 362 | return await ` + schemeFlipReadJS + `; |
| 363 | })()` |
| 364 | |
| 365 | // A scheme flip lands in the frame it happens in. The current nav |
| 366 | // item and a linked tile fade their background for the press and the |
| 367 | // hover, and a change of data-theme started the same fade while the |
| 368 | // text snapped: for about a tenth of a second after the click, light |
| 369 | // text sat on a still-light ground. Read on the very next frame, both |
| 370 | // backgrounds already equal what they settle to. The same through the |
| 371 | // OS flipping while the page is on System, where no form is involved. |
| 372 | func TestASchemeFlipLandsOnTheNextFrame(t *testing.T) { |
| 373 | rig := sizingRig(t, false, map[string]string{"/": sizingDoc("scheme flip", schemeFlipBody)}) |
| 374 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 375 | defer cancel() |
| 376 | os := func(scheme string) chromedp.Action { |
| 377 | return emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: scheme}}) |
| 378 | } |
| 379 | mustRun(t, ctx, os("light"), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), |
| 380 | chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) |
| 381 | // choose submits one of the switch's forms the way a click does, |
| 382 | // and reads the state on the next frame, in one script so no |
| 383 | // round trip sits between the two. |
| 384 | choose := func(scheme string) schemeFlipState { |
| 385 | t.Helper() |
| 386 | var got schemeFlipState |
| 387 | at(t, ctx, fmt.Sprintf(`(() => { document.querySelector('[rst-scheme] input[value=%q] ~ button').click(); return %s; })()`, scheme, schemeFlipReadJS), &got, awaitPromise) |
| 388 | return got |
| 389 | } |
| 390 | landed := func(where string) schemeFlipState { |
| 391 | t.Helper() |
| 392 | var got schemeFlipState |
| 393 | at(t, ctx, schemeFlipLandedJS, &got, awaitPromise) |
| 394 | t.Logf("%s, landed: %+v", where, got) |
| 395 | return got |
| 396 | } |
| 397 | check := func(where string, got, want schemeFlipState) { |
| 398 | t.Helper() |
| 399 | if got.Theme != want.Theme { |
| 400 | t.Fatalf("%s: data-theme %q, want %q", where, got.Theme, want.Theme) |
| 401 | } |
| 402 | if got.Nav != want.Nav || got.Tile != want.Tile { |
| 403 | t.Errorf("%s: on the next frame the nav item is %s and the tile %s; settled they are %s and %s. The flip faded instead of landing.", where, got.Nav, got.Tile, want.Nav, want.Tile) |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | // Learn the settled values of each scheme first, so the test holds |
| 408 | // the theme's own numbers rather than a copy of them. |
| 409 | choose("dark") |
| 410 | dark := landed("dark chosen") |
| 411 | choose("light") |
| 412 | light := landed("light chosen") |
| 413 | if dark.Nav == light.Nav || dark.Tile == light.Tile { |
| 414 | t.Fatalf("the page does not change with the scheme: dark %+v, light %+v", dark, light) |
| 415 | } |
| 416 | |
| 417 | check("Light to Dark by the switch", choose("dark"), dark) |
| 418 | landed("after Dark") |
| 419 | check("Dark to Light by the switch", choose("light"), light) |
| 420 | landed("after Light") |
| 421 | |
| 422 | // System, with the OS flipping underneath: the attribute is gone, |
| 423 | // light-dark() repaints on its own, and no form is submitted. Read |
| 424 | // on the second frame, not the next: Chromium finds the media |
| 425 | // change in the style update that starts the fade, and reports it |
| 426 | // to listeners at the start of the frame after, so the shim can |
| 427 | // land an OS flip one frame late at best. Without it the second |
| 428 | // frame is a tenth of the way into the fade, and still light. |
| 429 | got := choose("system") |
| 430 | if got.Theme != "" { |
| 431 | t.Fatalf("after choosing System data-theme is %q", got.Theme) |
| 432 | } |
| 433 | landed("system, OS light") |
| 434 | mustRun(t, ctx, os("dark")) |
| 435 | at(t, ctx, schemeFlipSecondFrameJS, &got, awaitPromise) |
| 436 | check("System, OS light to dark", got, schemeFlipState{Nav: dark.Nav, Tile: dark.Tile}) |
| 437 | landed("system, OS dark") |
| 438 | mustRun(t, ctx, os("light")) |
| 439 | at(t, ctx, schemeFlipSecondFrameJS, &got, awaitPromise) |
| 440 | check("System, OS dark to light", got, schemeFlipState{Nav: light.Nav, Tile: light.Tile}) |
| 441 | } |
| 442 | |
| 443 | // TestASchemeFlipLandsWhileHovered is the hovered case of the flip |
| 444 | // finding (rastrillo issue #104): a tile and a rail item under the |
| 445 | // pointer, with their hover tint settled, when an app's own script |
| 446 | // flips data-theme the way an app's switch does (no shipped switch, |
| 447 | // so nothing cancels a transition). The hover tint used to be a |
| 448 | // background-color transition, and a flip retargeted it: the one item |
| 449 | // under the pointer faded for 0.2 to 0.25s while its text changed at |
| 450 | // once. The tint now rides a registered percentage and is re-resolved |
| 451 | // from the scheme every frame, so the very next frame's background is |
| 452 | // the value the item settles to. Light to dark on the tile, dark to |
| 453 | // light on the rail item, each with the mouse really over it. |
| 454 | func TestASchemeFlipLandsWhileHovered(t *testing.T) { |
| 455 | body := strings.Replace(schemeFlipBody, `<a href="#x">`, `<a href="#x" id="nav-x">`, 1) |
| 456 | rig := sizingRig(t, false, map[string]string{"/": sizingDoc("hovered scheme flip", body)}, harness.WithHover()) |
| 457 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 458 | defer cancel() |
| 459 | mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}), |
| 460 | chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) |
| 461 | bg := func(id string) string { |
| 462 | t.Helper() |
| 463 | var got string |
| 464 | mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.getElementById(`+jsStr(id)+`)).backgroundColor`, &got)) |
| 465 | return got |
| 466 | } |
| 467 | settled := func(id string) string { |
| 468 | t.Helper() |
| 469 | var got string |
| 470 | mustRun(t, ctx, chromedp.Evaluate(`(async () => { await Promise.all(document.getAnimations().filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity).map(a => a.finished.catch(() => null))); return getComputedStyle(document.getElementById(`+jsStr(id)+`)).backgroundColor; })()`, &got, awaitPromise)) |
| 471 | return got |
| 472 | } |
| 473 | for _, c := range []struct{ id, from, to string }{{"tile", "light", "dark"}, {"nav-x", "dark", "light"}} { |
| 474 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", `+jsStr(c.from)+`), true`, nil)) |
| 475 | settled(c.id) |
| 476 | rest := bg(c.id) |
| 477 | // The shell fills the viewport and the tile sits below it. |
| 478 | mustRun(t, ctx, chromedp.Evaluate(`document.getElementById(`+jsStr(c.id)+`).scrollIntoView({block: "center"}), true`, nil)) |
| 479 | x, y, w, h := rect(t, ctx, "#"+c.id) |
| 480 | // Two frames after the move, so the hover's transition exists |
| 481 | // before settled waits on it. |
| 482 | mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, x+w/2, y+h/2), chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) |
| 483 | hovered := settled(c.id) |
| 484 | if hovered == rest { |
| 485 | t.Fatalf("#%s in %s: hovered it is %s, as at rest; the hover did not take, so the drive would prove nothing", c.id, c.from, rest) |
| 486 | } |
| 487 | // The app's flip and the next frame's read in one script, so no |
| 488 | // round trip sits between the write and the read. |
| 489 | var next string |
| 490 | mustRun(t, ctx, chromedp.Evaluate(`(() => { document.documentElement.setAttribute("data-theme", `+jsStr(c.to)+`); return new Promise(r => requestAnimationFrame(() => r(getComputedStyle(document.getElementById(`+jsStr(c.id)+`)).backgroundColor))); })()`, &next, awaitPromise)) |
| 491 | want := settled(c.id) |
| 492 | t.Logf("#%s hovered: %s in %s, %s a frame after the app set %s, settled %s", c.id, hovered, c.from, next, c.to, want) |
| 493 | if want == hovered { |
| 494 | t.Fatalf("#%s: the hover tint is %s in %s and in %s alike; the drive would prove nothing", c.id, hovered, c.from, c.to) |
| 495 | } |
| 496 | if next != want { |
| 497 | t.Errorf("#%s hovered, app script sets data-theme=%s: on the next frame it is %s; settled it is %s. The flip faded instead of landing.", c.id, c.to, next, want) |
| 498 | } |
| 499 | mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, 5, 5), chromedp.Sleep(300*time.Millisecond)) |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | // schemeFlipSecondFrameJS reads the state one frame later than |
| 504 | // schemeFlipReadJS, for the OS flip (see the test). |
| 505 | const schemeFlipSecondFrameJS = `new Promise(r => requestAnimationFrame(() => r(` + schemeFlipReadJS + `)))` |
| 506 | |
| 507 | // TestAnAppsOwnSchemeSwitchLandsAtRest: the shipped switch |
| 508 | // lands a flip by cancelling the running transitions (land(), above), |
| 509 | // but an app that sets data-theme from its own script, or that took |
| 510 | // the new stylesheets with an old fiesta.js, never calls it. The |
| 511 | // nav items', linked rows' and tiles' background transitions sit on |
| 512 | // their :hover and :active rules only, not at rest, so at rest a |
| 513 | // scheme change has no transition to start and lands on the next |
| 514 | // frame with no script's help. The page is the scheme-flip fixture; |
| 515 | // the flip is an attribute write from page script, the way an app's |
| 516 | // switch does it, read on the very next frame against the value the |
| 517 | // page settles to. |
| 518 | func TestAnAppsOwnSchemeSwitchLandsAtRest(t *testing.T) { |
| 519 | rig := sizingRig(t, false, map[string]string{"/": sizingDoc("app scheme flip", schemeFlipBody)}) |
| 520 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 521 | defer cancel() |
| 522 | mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}), |
| 523 | chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) |
| 524 | // set writes the attribute the way an app's own switch does and |
| 525 | // reads the next frame, in one script so no round trip sits |
| 526 | // between the write and the read. |
| 527 | set := func(scheme string) schemeFlipState { |
| 528 | t.Helper() |
| 529 | var got schemeFlipState |
| 530 | at(t, ctx, fmt.Sprintf(`(() => { document.documentElement.setAttribute("data-theme", %q); return %s; })()`, scheme, schemeFlipReadJS), &got, awaitPromise) |
| 531 | return got |
| 532 | } |
| 533 | settled := func() schemeFlipState { |
| 534 | t.Helper() |
| 535 | var got schemeFlipState |
| 536 | at(t, ctx, schemeFlipLandedJS, &got, awaitPromise) |
| 537 | return got |
| 538 | } |
| 539 | set("dark") |
| 540 | dark := settled() |
| 541 | set("light") |
| 542 | light := settled() |
| 543 | if dark.Nav == light.Nav || dark.Tile == light.Tile { |
| 544 | t.Fatalf("the page does not change with the scheme: dark %+v, light %+v", dark, light) |
| 545 | } |
| 546 | for _, c := range []struct { |
| 547 | to string |
| 548 | want schemeFlipState |
| 549 | }{{"dark", dark}, {"light", light}} { |
| 550 | got := set(c.to) |
| 551 | if got.Theme != c.to { |
| 552 | t.Fatalf("data-theme is %q after the app set %q", got.Theme, c.to) |
| 553 | } |
| 554 | if got.Nav != c.want.Nav || got.Tile != c.want.Tile { |
| 555 | t.Errorf("app script sets data-theme=%s: on the next frame the nav item is %s and the tile %s; settled they are %s and %s. The flip faded instead of landing, with no shipped switch to cancel it.", c.to, got.Nav, got.Tile, c.want.Nav, c.want.Tile) |
| 556 | } |
| 557 | settled() |
| 558 | } |
| 559 | } |
| 560 | |