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
3package gallery
4
5import (
6 "bytes"
7 "context"
8 "fmt"
9 "image/png"
10 "net/http"
11 "net/http/httptest"
12 "regexp"
13 "strconv"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "github.com/chromedp/cdproto/emulation"
20 cdppage "github.com/chromedp/cdproto/page"
21 "github.com/chromedp/chromedp"
22
23 "amadan.net/rastrillo/fiesta"
24 "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig"
25 "amadan.net/rastrillo/fiesta/internal/harness"
26)
27
28// Frames hidden until styled, and shown in the page's scheme: the
29// arm, seek and watch half of view.js, driven with a frame document
30// held at each of the moments a reader could see the wrong thing.
31// Rastrillo's drives; the held document is served without frame.js,
32// because frame.js copies the parent's scheme before the first paint
33// and these legs are about the window a document without one opens,
34// which is every document a navigation inside a frame loads from
35// elsewhere.
36
37// gate is one held response: a handler blocks on it until the leg has
38// looked at the state the hold keeps open. Opened at most once. A nil
39// gate holds nothing.
40type gate struct {
41 c chan struct{}
42 once sync.Once
43}
44
45func newGate() *gate { return &gate{c: make(chan struct{})} }
46
47func (g *gate) open() {
48 if g != nil {
49 g.once.Do(func() { close(g.c) })
50 }
51}
52
53func (g *gate) wait() {
54 if g != nil {
55 <-g.c
56 }
57}
58
59// holds are the places heldFrames can hold the first frame on a page:
60// start, its document before a byte of it is sent; sheet, the
61// stylesheets it links; parse, the rest of its document after the
62// body's first paragraph; load, an image in that rest; next and
63// nextLoad, the same two for the document a navigation inside the
64// frame fetches with ?next.
65type holds struct{ start, sheet, parse, load, next, nextLoad *gate }
66
67func (h *holds) release() {
68 for _, g := range []*gate{h.start, h.sheet, h.parse, h.load, h.next, h.nextLoad} {
69 g.open()
70 }
71}
72
73const held, heldNext = mountPath + "/held.png", mountPath + "/held-next.png"
74
75var (
76 firstFrame = regexp.MustCompile(`<iframe class="ds-view__frame"[^>]*\ssrc="([^"]+)"`)
77 frameScript = regexp.MustCompile(`<script src="[^"]*/frame\.[0-9a-f]{16}\.js"></script>\n?`)
78)
79
80// heldFrames serves the gallery with the first preview frame on target
81// held at h. Its document is streamed up to its body and one paragraph
82// before the rest, so a parse hold has a body to look at.
83func heldFrames(t *testing.T, target string, h *holds) (*harness.Rig, string) {
84 t.Helper()
85 files := render(t, mountPath)
86 m := firstFrame.FindSubmatch(files[strings.TrimPrefix(target, mountPath+"/")])
87 if m == nil {
88 t.Fatalf("no preview frame on %s", target)
89 }
90 frameSrc := string(m[1])
91 rig := harness.New(t, func(string) http.Handler {
92 tree := galleryrig.Tree(t, files, mountPath+"/")
93 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94 switch r.URL.Path {
95 case held:
96 h.load.wait()
97 http.NotFound(w, r)
98 return
99 case heldNext:
100 h.nextLoad.wait()
101 http.NotFound(w, r)
102 return
103 }
104 if r.URL.RawQuery == "held" {
105 h.sheet.wait()
106 }
107 if r.URL.Path != frameSrc {
108 tree.ServeHTTP(w, r)
109 return
110 }
111 rec := httptest.NewRecorder()
112 tree.ServeHTTP(rec, r)
113 body := frameScript.ReplaceAllString(rec.Body.String(), "")
114 hold, img := h.parse, held
115 if r.URL.RawQuery == "next" {
116 hold, img = h.next, heldNext
117 } else {
118 h.start.wait()
119 if h.sheet != nil {
120 body = strings.ReplaceAll(body, `.css">`, `.css?held">`)
121 }
122 }
123 loc := bodyTag.FindStringIndex(body)
124 if loc == nil {
125 t.Errorf("the held document has no <body>")
126 return
127 }
128 tag := `<img src="` + img + `" alt="" width="1" height="1">`
129 body = body[:loc[1]] + tag + body[loc[1]:]
130 cut := loc[1] + len(tag)
131 w.Header().Set("Content-Type", "text/html; charset=utf-8")
132 w.Write([]byte(body[:cut] + "<p>Held.</p>"))
133 w.(http.Flusher).Flush()
134 hold.wait()
135 w.Write([]byte(body[cut:]))
136 })
137 })
138 rig.Allow(http.MethodGet, held, http.StatusNotFound)
139 rig.Allow(http.MethodGet, heldNext, http.StatusNotFound)
140 return rig, frameSrc
141}
142
143// openChosen sets the gallery's scheme to chosen with the OS in os
144// (system is no stored choice), then opens target without waiting for
145// its load, which a held frame holds back.
146func openChosen(t *testing.T, ctx context.Context, rig *harness.Rig, os, chosen, target string) {
147 t.Helper()
148 mustRun(t, ctx,
149 emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: os}}),
150 chromedp.EmulateViewport(1280, 900),
151 chromedp.Navigate(rig.Origin+href(fiesta.ThemeNames()[0]+"/en/index.html")),
152 chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "`+chosen+`"); if ("`+chosen+`" === "system") localStorage.removeItem("fiesta-scheme"); true`, nil),
153 chromedp.Navigate("about:blank"),
154 )
155 if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
156 _, _, _, _, err := cdppage.Navigate(rig.Origin + target).Do(ctx)
157 return err
158 })); err != nil {
159 t.Fatalf("opening the page: %v", err)
160 }
161}
162
163// requireHeldPage waits for a page opened without waiting on its load
164// (a held frame holds that back) to be parsed, then requires the
165// widget on it.
166func requireHeldPage(t *testing.T, ctx context.Context, where string) {
167 t.Helper()
168 until(t, ctx, where+", the page parsed", `document.readyState !== "loading" && !!document.querySelector(".ds-view__frame")`)
169 requireWidget(t, ctx, where)
170}
171
172// schemeApplied is the page's own script having written the chosen
173// scheme on <html>, so a reading of the page's colour is of the chosen
174// one and not of the moment before the deferred script ran.
175func schemeApplied(chosen string) string {
176 if chosen == "system" {
177 return `!document.documentElement.hasAttribute("data-theme")`
178 }
179 return `document.documentElement.getAttribute("data-theme") === ` + strconv.Quote(chosen)
180}
181
182func twoFrames(t *testing.T, ctx context.Context, where string) {
183 t.Helper()
184 mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, awaitPromise))
185}
186
187// frameReading is one look at the first frame: the background its own
188// document paints, the page's, whether the frame is visible, its own
189// color-scheme, the pixel the reader gets inside the frame's box, and
190// how many pixels across the whole box differ from the page's
191// background.
192type frameReading struct {
193 Frame, Page, Seen, Shown, Scheme string
194 X, Y float64
195 Box [4]float64
196 Off int
197}
198
199func readFrame(t *testing.T, ctx context.Context, where string, r *frameReading) {
200 t.Helper()
201 var shot []byte
202 at(t, ctx, `(() => {
203 const f = document.querySelector(".ds-view__frame"), b = f.getBoundingClientRect(), x = f.parentElement.getBoundingClientRect();
204 const rgb = s => s.replace(/\s+/g, "");
205 return JSON.stringify({Frame: rgb(getComputedStyle(f.contentDocument.body).backgroundColor),
206 Page: rgb(getComputedStyle(document.body).backgroundColor), Shown: getComputedStyle(f).visibility,
207 Scheme: getComputedStyle(f).colorScheme, X: b.left + b.width / 2, Y: b.top + 8,
208 Box: [x.left, x.top, x.right, Math.min(x.bottom, innerHeight)]});
209 })()`, r)
210 mustRun(t, ctx, chromedp.CaptureScreenshot(&shot))
211 img, err := png.Decode(bytes.NewReader(shot))
212 if err != nil {
213 t.Fatalf("%s: decoding the screenshot: %v", where, err)
214 }
215 pixel := func(x, y int) string {
216 cr, cg, cb, _ := img.At(x, y).RGBA()
217 return fmt.Sprintf("rgb(%d,%d,%d)", cr>>8, cg>>8, cb>>8)
218 }
219 r.Seen = pixel(int(r.X), int(r.Y))
220 // Inset past the box's border, its rounded corners and the resize
221 // grip in its end corner, which are the page's own drawing.
222 const inset = 6
223 for y := int(r.Box[1]) + inset; y < int(r.Box[3])-inset; y++ {
224 for x := int(r.Box[0]) + inset; x < int(r.Box[2])-inset; x++ {
225 if y > int(r.Box[3])-20 && (x > int(r.Box[2])-20 || x < int(r.Box[0])+20) {
226 continue
227 }
228 if pixel(x, y) != r.Page {
229 r.Off++
230 }
231 }
232 }
233}
234
235// TestAFrameNeverShowsTheOtherSchemeWhileItLoads: a frame never shows
236// the reader the other scheme, and shows as soon as it can be painted.
237// Held half-parsed, the reader sees the page's background (the
238// control proves the frame's own document is in the other scheme);
239// parsed with an image still loading, it shows, painted; loaded, it
240// still shows.
241func TestAFrameNeverShowsTheOtherSchemeWhileItLoads(t *testing.T) {
242 for _, c := range []struct{ os, chosen string }{{"light", "dark"}, {"dark", "light"}} {
243 where := "the OS in " + c.os + ", the gallery in " + c.chosen
244 t.Run(c.os+"-os-"+c.chosen+"-gallery", func(t *testing.T) {
245 h := &holds{parse: newGate(), load: newGate()}
246 defer h.release()
247 target := href(fiesta.ThemeNames()[0] + "/en/field.html")
248 rig, frameSrc := heldFrames(t, target, h)
249 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
250 defer cancel()
251 openChosen(t, ctx, rig, c.os, c.chosen, target)
252 requireHeldPage(t, ctx, where)
253 first := `document.querySelector(".ds-view__frame")`
254 until(t, ctx, where+", the frame half-parsed",
255 `(() => { const d = `+first+`.contentDocument; return `+schemeApplied(c.chosen)+` && !!d && d.URL.endsWith(`+strconv.Quote(frameSrc)+`) && d.readyState === "loading" && !!d.body; })()`)
256 twoFrames(t, ctx, where)
257 var during frameReading
258 readFrame(t, ctx, where+", while it parses", &during)
259 if during.Frame == during.Page {
260 t.Fatalf("%s: the half-parsed frame's document is already painted %s, the gallery's colour; the window this leg is about was never open", where, during.Frame)
261 }
262 if during.Seen != during.Page {
263 t.Errorf("%s: while the frame parses the reader sees %s in its box, the frame's own %s scheme, on a page painted %s", where, during.Seen, c.os, during.Page)
264 }
265
266 h.parse.open()
267 until(t, ctx, where+", parsed with its load held", first+`.contentDocument.readyState === "interactive"`)
268 twoFrames(t, ctx, where)
269 var parsed frameReading
270 readFrame(t, ctx, where+", parsed", &parsed)
271 if parsed.Frame != parsed.Page || parsed.Seen != parsed.Page || parsed.Shown != "visible" {
272 t.Errorf("%s: once parsed, with an image still loading, the frame must show in the gallery's scheme: its document is %s, the reader sees %s (visibility %s), the page is %s", where, parsed.Frame, parsed.Seen, parsed.Shown, parsed.Page)
273 }
274
275 h.load.open()
276 until(t, ctx, where+", loaded", first+`.contentDocument.readyState === "complete"`)
277 twoFrames(t, ctx, where)
278 var after frameReading
279 readFrame(t, ctx, where+", loaded", &after)
280 if after.Frame != after.Page || after.Seen != after.Page || after.Shown != "visible" {
281 t.Errorf("%s: once loaded the frame must show in the gallery's scheme: its document is %s, the reader sees %s (visibility %s), the page is %s", where, after.Frame, after.Seen, after.Shown, after.Page)
282 }
283 })
284 }
285}
286
287// TestAFrameShowsNothingUntilItsDocumentIsStyled: on System, a frame
288// shows the reader nothing but the page's own background while it
289// still holds about:blank and while its document is parsed and
290// unstyled; with the sheets let through it shows, in the page's
291// colour. The frame's color-scheme is normal, so Safari paints the
292// blank frame transparent rather than white.
293func TestAFrameShowsNothingUntilItsDocumentIsStyled(t *testing.T) {
294 for _, os := range []string{"dark", "light"} {
295 where := "the OS in " + os + ", the gallery on System"
296 t.Run(os+"-os-system-gallery", func(t *testing.T) {
297 h := &holds{start: newGate(), sheet: newGate()}
298 defer h.release()
299 target := href(fiesta.ThemeNames()[0] + "/en/field.html")
300 rig, frameSrc := heldFrames(t, target, h)
301 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
302 defer cancel()
303 openChosen(t, ctx, rig, os, "system", target)
304 requireHeldPage(t, ctx, where)
305 first := `document.querySelector(".ds-view__frame")`
306 until(t, ctx, where+", the frame still blank",
307 `(() => { const f = `+first+`; return !!f && !!f.contentDocument && f.contentDocument.URL === "about:blank" && !document.documentElement.hasAttribute("data-theme"); })()`)
308 twoFrames(t, ctx, where)
309 var blank frameReading
310 readFrame(t, ctx, where+", still about:blank", &blank)
311 if blank.Shown != "hidden" || blank.Off != 0 {
312 t.Errorf("%s: while the frame still holds about:blank it is %s, and %d pixels in its box are not the page's %s", where, blank.Shown, blank.Off, blank.Page)
313 }
314 if blank.Scheme != "normal" {
315 t.Errorf("%s: the frame element's color-scheme is %q; anything that can resolve dark has Safari paint the blank frame white", where, blank.Scheme)
316 }
317
318 h.start.open()
319 until(t, ctx, where+", parsed with its stylesheets held",
320 `(() => { const d = `+first+`.contentDocument; return !!d && d.URL.endsWith(`+strconv.Quote(frameSrc)+`) && d.readyState !== "loading"; })()`)
321 twoFrames(t, ctx, where)
322 var bare frameReading
323 readFrame(t, ctx, where+", parsed, unstyled", &bare)
324 if bare.Frame != "rgba(0,0,0,0)" {
325 t.Fatalf("%s: the parsed frame's body is already %s; its stylesheets were not held, and the window this leg is about was never open", where, bare.Frame)
326 }
327 if bare.Shown != "hidden" || bare.Off != 0 {
328 t.Errorf("%s: parsed with its stylesheets still loading, the frame is %s, and %d pixels in its box are not the page's %s; its unstyled document would show", where, bare.Shown, bare.Off, bare.Page)
329 }
330
331 h.sheet.open()
332 until(t, ctx, where+", shown", `getComputedStyle(`+first+`).visibility === "visible"`)
333 twoFrames(t, ctx, where)
334 var shown frameReading
335 readFrame(t, ctx, where+", styled", &shown)
336 if shown.Frame != shown.Page || shown.Seen != shown.Page {
337 t.Errorf("%s: once styled the frame must show in the page's scheme: its document is %s, the reader sees %s, the page is %s", where, shown.Frame, shown.Seen, shown.Page)
338 }
339 })
340 }
341}
342
343// TestAFrameNeverMovesTheReader: a frame never takes the reader's
344// focus, or moves the page, on its own. The stage layout's document
345// puts autofocus on its email field, which is right for the page and
346// wrong inside the gallery; frame.js holds it inert until after load,
347// and lets it go after, so a reader can still use it.
348func TestAFrameNeverMovesTheReader(t *testing.T) {
349 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
350 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
351 defer cancel()
352 theme := fiesta.ThemeNames()[0]
353 stage := `document.querySelector('.ds-view__frame[src=` + strconv.Quote(href(frameFile(theme, "en", "layout-stage", mustDefault(t, "layout-stage")))) + `]')`
354 // A short window, so the field inside the stage is under the fold
355 // and focusing it would have to scroll the page; the frame is still
356 // within the distance a lazy frame loads at.
357 openPage(t, ctx, "layout-stage at 1280x300", pageURL(rig, "en", "layout-stage.html"), 1280, 300)
358 until(t, ctx, "the stage layout loaded",
359 `(() => { const f = `+stage+`, d = f && f.contentDocument; return !!d && d.URL.endsWith("/default.html") && d.readyState === "complete" && f.hasAttribute("data-gallery-painted"); })()`)
360 twoFrames(t, ctx, "the stage layout")
361 var got struct {
362 Autofocus, Inert bool
363 Y, FieldTop float64
364 Active string
365 }
366 mustRun(t, ctx, chromedp.Sleep(200*time.Millisecond))
367 at(t, ctx, `(async () => { const f = `+stage+`, i = f.contentDocument.querySelector("[autofocus]");
368 const k = new DOMMatrix(getComputedStyle(f).transform).a || 1;
369 return JSON.stringify({Autofocus: (await (await fetch(f.src)).text()).includes(" autofocus"), Y: scrollY,
370 Inert: i ? i.inert : false, FieldTop: i ? f.getBoundingClientRect().top + i.getBoundingClientRect().top * k : -1,
371 Active: document.activeElement === document.body ? "body" : document.activeElement.outerHTML.slice(0, 120)}); })()`, &got, awaitPromise)
372 if !got.Autofocus || got.FieldTop < 0 {
373 t.Fatalf("the stage layout is served with no autofocus field; this leg needs a framed document that asks for focus")
374 }
375 if got.FieldTop < 300 {
376 t.Fatalf("the autofocus field is at %.0fpx in a 300px window; focusing it would not have to scroll, so the leg proves nothing", got.FieldTop)
377 }
378 if got.Active != "body" || got.Y != 0 {
379 t.Errorf("the stage layout's frame took the reader: focus is on %s, and the page moved to %v", got.Active, got.Y)
380 }
381 if got.Inert {
382 t.Errorf("the stage layout's field is still inert after its frame loaded; the reader cannot use it")
383 }
384}
385
386// TestAFrameHidesWhileItNavigates: a navigation inside a frame gets the
387// same hiding and the same showing as the first load. Two legs, one per
388// document the navigation leaves: one whose load has fired, and one
389// shown at parse whose image is still held.
390func TestAFrameHidesWhileItNavigates(t *testing.T) {
391 const os, chosen = "light", "dark"
392 for _, c := range []struct {
393 name string
394 loaded bool
395 }{{"from a loaded document", true}, {"from a document still loading", false}} {
396 t.Run(strings.ReplaceAll(c.name, " ", "-"), func(t *testing.T) {
397 h := &holds{load: newGate(), next: newGate(), nextLoad: newGate()}
398 defer h.release()
399 ready := `.contentDocument.readyState === "interactive"`
400 if c.loaded {
401 h.load.open()
402 ready = `.contentDocument.readyState === "complete"`
403 }
404 target := href(fiesta.ThemeNames()[0] + "/en/field.html")
405 rig, frameSrc := heldFrames(t, target, h)
406 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
407 defer cancel()
408 openChosen(t, ctx, rig, os, chosen, target)
409 requireHeldPage(t, ctx, c.name)
410 first := `document.querySelector(".ds-view__frame")`
411 until(t, ctx, c.name+": the first document shown",
412 schemeApplied(chosen)+` && !!`+first+`.contentDocument && `+first+`.contentDocument.URL.endsWith(`+strconv.Quote(frameSrc)+`) && `+first+ready+` && `+first+`.hasAttribute("data-gallery-painted")`)
413
414 mustRun(t, ctx, chromedp.Evaluate(first+`.contentWindow.location.href = `+strconv.Quote(frameSrc+"?next")+`, true`, nil))
415 until(t, ctx, c.name+": the next document half-parsed",
416 `(() => { const d = `+first+`.contentDocument; return !!d && d.URL.endsWith("?next") && d.readyState === "loading" && !!d.body; })()`)
417 twoFrames(t, ctx, c.name)
418 var during frameReading
419 readFrame(t, ctx, c.name+": the next document, while it parses", &during)
420 if during.Frame == during.Page {
421 t.Fatalf("%s: the half-parsed next document is already painted %s, the gallery's colour; the window this leg is about was never open", c.name, during.Frame)
422 }
423 if during.Seen != during.Page {
424 t.Errorf("%s: while the frame's next document parses the reader sees %s in its box, the OS's %s scheme, on a page painted %s", c.name, during.Seen, os, during.Page)
425 }
426
427 h.next.open()
428 until(t, ctx, c.name+": the next document parsed with its load held",
429 first+`.contentDocument.URL.endsWith("?next") && `+first+`.contentDocument.readyState === "interactive"`)
430 twoFrames(t, ctx, c.name)
431 var parsed frameReading
432 readFrame(t, ctx, c.name+": the next document, parsed", &parsed)
433 if parsed.Frame != parsed.Page || parsed.Seen != parsed.Page || parsed.Shown != "visible" {
434 t.Errorf("%s: once the next document is parsed, with an image still loading, the frame must show in the gallery's scheme: its document is %s, the reader sees %s (visibility %s), the page is %s", c.name, parsed.Frame, parsed.Seen, parsed.Shown, parsed.Page)
435 }
436
437 h.nextLoad.open()
438 until(t, ctx, c.name+": the next document loaded", first+`.contentDocument.URL.endsWith("?next") && `+first+`.contentDocument.readyState === "complete"`)
439 twoFrames(t, ctx, c.name)
440 var after frameReading
441 readFrame(t, ctx, c.name+": the next document, loaded", &after)
442 if after.Frame != after.Page || after.Seen != after.Page || after.Shown != "visible" {
443 t.Errorf("%s: once the next document loads the frame must show in the gallery's scheme: its document is %s, the reader sees %s (visibility %s), the page is %s", c.name, after.Frame, after.Seen, after.Shown, after.Page)
444 }
445 })
446 }
447}
448
449// TestAFrameStopsLookingForItsNextDocument: after pagehide the gallery
450// looks for the frame's next document every 16ms; the look ends when
451// the frame leaves the page, and gives up after a bound, showing the
452// frame unpainted. The frame is sent to a document the gallery cannot
453// read (a data: URL) whose load hangs on a held image.
454func TestAFrameStopsLookingForItsNextDocument(t *testing.T) {
455 const os, chosen = "light", "dark"
456 const count = `(() => {
457 const set = window.setTimeout;
458 window.dsLooks = 0;
459 window.setTimeout = function (fn, ms) {
460 if (ms === 16) window.dsLooks++;
461 return set.apply(this, arguments);
462 };
463 })()`
464 for _, c := range []struct {
465 name string
466 remove bool
467 }{{"a navigation that never loads", false}, {"a frame removed while it navigates", true}} {
468 t.Run(strings.ReplaceAll(c.name, " ", "-"), func(t *testing.T) {
469 h := &holds{nextLoad: newGate()}
470 defer h.release()
471 target := href(fiesta.ThemeNames()[0] + "/en/field.html")
472 rig, frameSrc := heldFrames(t, target, h)
473 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
474 defer cancel()
475 mustRun(t, ctx, addInit(count))
476 openChosen(t, ctx, rig, os, chosen, target)
477 requireHeldPage(t, ctx, c.name)
478 first := `document.querySelector(".ds-view__frame")`
479 until(t, ctx, c.name+": the first document shown",
480 `!!`+first+`.contentDocument && `+first+`.contentDocument.URL.endsWith(`+strconv.Quote(frameSrc)+`) && `+first+`.contentDocument.readyState === "complete" && `+first+`.hasAttribute("data-gallery-painted")`)
481
482 elsewhere := `<!doctype html><p>Elsewhere.</p><img src="` + rig.Origin + heldNext + `" alt="" width="1" height="1">`
483 mustRun(t, ctx, chromedp.Evaluate(first+`.src = "data:text/html," + encodeURIComponent(`+strconv.Quote(elsewhere)+`), true`, nil))
484 until(t, ctx, c.name+": looking for the next document", `window.dsLooks > 3 && !`+first+`.hasAttribute("data-gallery-painted")`)
485
486 if c.remove {
487 mustRun(t, ctx, chromedp.Evaluate(first+`.remove(), true`, nil))
488 } else {
489 until(t, ctx, c.name+": the frame shown once the look gives up",
490 first+`.hasAttribute("data-gallery-painted") && getComputedStyle(`+first+`).visibility === "visible"`)
491 }
492 var before, after int
493 mustRun(t, ctx,
494 chromedp.Sleep(100*time.Millisecond),
495 chromedp.Evaluate(`window.dsLooks`, &before),
496 chromedp.Sleep(500*time.Millisecond),
497 chromedp.Evaluate(`window.dsLooks`, &after),
498 )
499 if after != before {
500 t.Errorf("%s: the gallery still looks for the frame's next document, %d times in half a second", c.name, after-before)
501 }
502 })
503 }
504}
505
506// switchReadJS reads, on the next animation frame (a frame later with
507// later, for the OS flip), the page's scheme and background and every
508// frame's scheme attribute and background, so a flip that lands in
509// one frame reads the same as the settled state.
510func switchReadJS(later bool) string {
511 read := `new Promise(r => requestAnimationFrame(() => r(JSON.stringify({
512 Theme: document.documentElement.getAttribute("data-theme") || "",
513 Page: getComputedStyle(document.body).backgroundColor,
514 Frames: [...document.querySelectorAll(".ds-view__frame")].map(f => { const d = f.contentDocument; return d ? (d.documentElement.getAttribute("data-theme") || "") + " " + getComputedStyle(d.body).backgroundColor : "?"; })
515 }))))`
516 if later {
517 return `new Promise(r => requestAnimationFrame(() => r(` + read + `)))`
518 }
519 return read
520}
521
522// TestTheGallerySwitchLandsOnTheNextFrame: a scheme chosen in the
523// header lands in the frame it happens in, on the page and in every
524// framed document alike: read on the very next animation frame, the
525// page and the frames are already at the values they settle to.
526// Through the OS flipping under System, a frame later.
527func TestTheGallerySwitchLandsOnTheNextFrame(t *testing.T) {
528 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
529 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
530 defer cancel()
531 where := "field at 1280px"
532 osScheme := func(scheme string) chromedp.Action {
533 return emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: scheme}})
534 }
535 mustRun(t, ctx, osScheme("light"))
536 openPage(t, ctx, where, pageURL(rig, "en", "field.html"), 1280, 900)
537 n := painted(t, ctx, where)
538 type state struct {
539 Theme, Page string
540 Frames []string
541 }
542 read := func(leg, js string) state {
543 t.Helper()
544 var got state
545 at(t, ctx, js, &got, awaitPromise)
546 if len(got.Frames) != n {
547 t.Fatalf("%s, %s: read %d frames, %d loaded", where, leg, len(got.Frames), n)
548 }
549 return got
550 }
551 choose := func(scheme string) state {
552 t.Helper()
553 return read("choosing "+scheme, `(() => { const menus = [...document.querySelectorAll("[rst-scheme]")], m = menus.find(m => m.checkVisibility()) || menus[0]; m.open = true; m.querySelector('input[value="`+scheme+`"] ~ button').click(); return `+switchReadJS(false)+`; })()`)
554 }
555 landed := func(leg string) state {
556 t.Helper()
557 got := read(leg, `(async () => { await Promise.all(document.getAnimations().filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity).map(a => a.finished.catch(() => null))); return await `+switchReadJS(false)+`; })()`)
558 t.Logf("%s, %s, landed: theme %q, page %s, frames %v", where, leg, got.Theme, got.Page, got.Frames[:1])
559 return got
560 }
561 check := func(leg string, got, want state) {
562 t.Helper()
563 if got.Theme != want.Theme {
564 t.Fatalf("%s, %s: data-theme %q, want %q", where, leg, got.Theme, want.Theme)
565 }
566 if got.Page != want.Page {
567 t.Errorf("%s, %s: the page is %s on the next frame; settled it is %s. The flip faded instead of landing.", where, leg, got.Page, want.Page)
568 }
569 for i := range got.Frames {
570 if got.Frames[i] != want.Frames[i] {
571 t.Errorf("%s, %s: frame %d is %q on the next frame; settled it is %q. The frames did not follow the switch in the same frame.", where, leg, i, got.Frames[i], want.Frames[i])
572 }
573 }
574 }
575 choose("dark")
576 dark := landed("Dark chosen")
577 choose("light")
578 light := landed("Light chosen")
579 if dark.Page == light.Page || dark.Frames[0] == light.Frames[0] {
580 t.Fatalf("%s: the page or the frames do not change with the scheme: %s / %s", where, dark.Page, dark.Frames[0])
581 }
582 check("Light to Dark by the switch", choose("dark"), dark)
583 landed("after Dark")
584 check("Dark to Light by the switch", choose("light"), light)
585 landed("after Light")
586 system := choose("system")
587 settled := landed("System, OS light")
588 check("System chosen", system, settled)
589 mustRun(t, ctx, osScheme("dark"))
590 flipped := read("OS dark", switchReadJS(true))
591 settledDark := landed("System, OS dark")
592 check("System, OS light to dark", flipped, settledDark)
593 if settledDark.Page == settled.Page {
594 t.Fatalf("%s: the OS flip changed nothing: the page is %s either way", where, settled.Page)
595 }
596 mustRun(t, ctx, osScheme("light"))
597 check("System, OS dark to light", read("OS light", switchReadJS(true)), landed("System, OS light again"))
598}
599