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 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.
7package check
8
9import (
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.
32const 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.
46func 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.
64func 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.
74type 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
85const 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.
106func 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.
125func 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
168func 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.
176func 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.
203func 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.
245func 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.
265func 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.
288func 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.
332const 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.
342type 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.
352const 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.
360const 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.
372func 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.
454func 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).
505const 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.
518func 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