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 check
4
5import (
6 "context"
7 "fmt"
8 "net/http"
9 "os"
10 "sort"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/chromedp/chromedp"
16
17 "amadan.net/rastrillo/fiesta"
18 "amadan.net/rastrillo/fiesta/internal/harness"
19)
20
21// The parity drive holds 1.0.0's appearance across 1.x: the depth
22// fixture's matrix and 1.0.0's seven layouts, under 1.0.0's CSS
23// (testdata/seed/) and under the CSS this tree ships (css/), must agree
24// to the pixel. It says which theme, which cell and which page moved
25// when css/ does. The layouts are 1.0.0's own documents, from
26// testdata/seed/examples/: the tree's current layouts are 1.x markup,
27// and photographing them under CSS that never styled them compares
28// nothing a 1.0 site has.
29// What a still photograph cannot see is shell.css's own content: the
30// phone-width cross-document view transitions, which pageShot skips
31// before every shot. The drive holds shell.css to what it paints on a
32// settled page, which today is nothing; the slide itself is not under
33// this gate.
34//
35// parityRig is legacyRig's shape for a whole asset set: two copies of
36// the same documents at /old/<name> and /new/<name>, each beside its
37// own tokens.css, theme.css, shell.css and scripts, which the documents
38// link relatively (parityLinks), so each side resolves its own.
39func parityRig(t *testing.T, docs map[string]string, oldAssets, newAssets map[string][]byte) *harness.Rig {
40 t.Helper()
41 sides := map[string]map[string][]byte{"old": oldAssets, "new": newAssets}
42 return harness.New(t, func(string) http.Handler {
43 mux := http.NewServeMux()
44 fixtureImages(mux)
45 // One literal route per side rather than /{side}/{file}: Go's
46 // mux calls that and /images/ a conflict, since neither is the
47 // more specific.
48 for side, assets := range sides {
49 assets := assets
50 mux.HandleFunc("GET /"+side+"/{file}", func(w http.ResponseWriter, r *http.Request) {
51 file := r.PathValue("file")
52 if doc, ok := docs[file]; ok {
53 w.Header().Set("Content-Type", "text/html; charset=utf-8")
54 fmt.Fprint(w, doc)
55 return
56 }
57 body, ok := assets[file]
58 if !ok {
59 http.NotFound(w, r)
60 return
61 }
62 switch {
63 case strings.HasSuffix(file, ".css"):
64 w.Header().Set("Content-Type", "text/css")
65 case strings.HasSuffix(file, ".js"):
66 w.Header().Set("Content-Type", "text/javascript")
67 }
68 w.Write(body)
69 })
70 }
71 return mux
72 }, harness.WithHover(), harness.WithDeterministicRaster())
73}
74
75// parityAssets is one side's asset set: the three stylesheets under the
76// flat names the documents link, and every shipped script. The scripts
77// are the tree's on both sides: there is no seed copy of js/, and the
78// scripts are not under the baseline, so the drive compares the CSS
79// with the scripts held equal.
80func parityAssets(tokens, theme, shell []byte) map[string][]byte {
81 assets := map[string][]byte{"tokens.css": tokens, "theme.css": theme, "shell.css": shell}
82 for _, name := range fiesta.ScriptNames() {
83 js, ok := fiesta.Script(name)
84 if !ok {
85 panic("fiesta: " + name + " is named but not shipped")
86 }
87 assets[name] = js
88 }
89 return assets
90}
91
92// seedFile reads one file of the synced baseline, testdata/seed/.
93func seedFile(t *testing.T, name string) []byte {
94 t.Helper()
95 b, err := os.ReadFile("testdata/seed/" + name)
96 if err != nil {
97 t.Fatal(err)
98 }
99 return b
100}
101
102// pageShot photographs one whole page at one width in one scheme,
103// frozen: the viewport is set before the navigation (a change after
104// it turns the hover query off in this Chromium), a cross-document
105// view transition the shells run is skipped and waited out, the scheme
106// is pinned on the root, and still() stops every transition and holds
107// the light straight above before the shot.
108func pageShot(t *testing.T, ctx context.Context, url string, width int64, scheme string) []byte {
109 t.Helper()
110 return pageShotChecked(t, ctx, url, width, scheme, nil)
111}
112
113// pageShotChecked is pageShot with check run on the settled page before
114// it is frozen and photographed: the 1.0 photographs hold every picture
115// loaded and every request answered there, so two identical broken
116// pages cannot pass as two identical pages.
117func pageShotChecked(t *testing.T, ctx context.Context, url string, width int64, scheme string, check func()) []byte {
118 t.Helper()
119 height := int64(900)
120 if width < 800 {
121 height = 844
122 }
123 mustRun(t, ctx, chromedp.EmulateViewport(width, height), chromedp.Navigate(url), chromedp.WaitReady("body", chromedp.ByQuery),
124 chromedp.Evaluate(`new Promise(r => { const vt = document.activeViewTransition; if (!vt) return r(true); vt.skipTransition(); vt.finished.then(() => r(true), () => r(true)); })`, nil, awaitPromise),
125 chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil))
126 if check != nil {
127 check()
128 }
129 still(t, ctx)
130 // light.js writes the held light in its next animation frame, not
131 // at the event, so a shot taken at once sometimes caught a button's
132 // sheen before the hold: two shots of one page under one stylesheet
133 // differed by a pixel, 3 pairs in 18. Two frames later it is painted.
134 mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, awaitPromise))
135 var buf []byte
136 mustRun(t, ctx, chromedp.FullScreenshot(&buf, 100))
137 return buf
138}
139
140// TestEveryThemeAndShellRendersAsTheBaseline photographs the matrix
141// targets on the depth fixture and 1.0.0's seven layout documents at
142// 1280px and 390px, under the baseline CSS and under the tree's, across
143// the three themes and both schemes, and holds every crop and every
144// page to the pixel. One rig per theme, since the theme is an asset of
145// the rig; the scheme is pinned on each page. Adapted preservation: the
146// layouts are read from testdata/seed/examples/ rather than the tree's
147// examples, and fewer than seven fails.
148func TestEveryThemeAndShellRendersAsTheBaseline(t *testing.T) {
149 docs := map[string]string{"depth": parityLinks(sizingDoc("depth", depthBody))}
150 layouts := oneOhLayouts(t)
151 for _, layout := range layouts {
152 docs[layout] = parityLinks(string(seedFile(t, "examples/"+layout+"/default.html")))
153 }
154 for _, theme := range fiesta.ThemeNames() {
155 t.Run(theme, func(t *testing.T) {
156 themeCSS, ok := fiesta.ThemeCSS(theme)
157 if !ok {
158 t.Fatalf("no theme %q", theme)
159 }
160 old := parityAssets(seedFile(t, "tokens.css"), seedFile(t, theme+".css"), seedFile(t, "shell.css"))
161 now := parityAssets(fiesta.TokensCSS(), themeCSS, fiesta.ShellCSS())
162 rig := parityRig(t, docs, old, now)
163 ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second)
164 defer cancel()
165 covered := false
166 for _, scheme := range []string{"light", "dark"} {
167 was, wasAreas := matrixShotsExcept(t, ctx, rig.Origin, "/old/depth", scheme, consoleRailRing)
168 is, isAreas := matrixShotsExcept(t, ctx, rig.Origin, "/new/depth", scheme, consoleRailRing)
169 if why := sameAreas(wasAreas, isAreas); why != "" {
170 t.Errorf("%s: the depth fixture in %s: %s", theme, scheme, why)
171 }
172 keys := make([]string, 0, len(was))
173 for key := range was {
174 keys = append(keys, key)
175 }
176 sort.Strings(keys)
177 if len(keys) == 0 {
178 t.Fatalf("%s: the matrix photographed nothing in %s", theme, scheme)
179 }
180 for _, key := range keys {
181 if ok, why := samePixelsWithin(t, was[key], is[key], 0); !ok {
182 t.Errorf("%s: %s renders differently from the baseline: %s", theme, key, why)
183 }
184 }
185 for _, layout := range layouts {
186 for _, width := range []int64{1280, 390} {
187 before, beforeAreas := pageShotExcept(t, ctx, rig.Origin+"/old/"+layout, width, scheme, consoleRailRing)
188 after, afterAreas := pageShotExcept(t, ctx, rig.Origin+"/new/"+layout, width, scheme, consoleRailRing)
189 ok, why, changed := samePixelsExcept(t, before, after, beforeAreas, afterAreas)
190 if !ok {
191 t.Errorf("%s: %s at %dpx in %s renders differently from the baseline: %s", theme, layout, width, scheme, why)
192 }
193 covered = covered || changed
194 }
195 }
196 }
197 // Day draws the ring and its shadow, so a Day run where the
198 // exception covered no change is one where the fix is gone
199 // or the exception has drifted off it.
200 if theme == "day" && !covered {
201 t.Errorf("day: consoleRailRing covered no change in any layout; %s, and an exception for nothing is a hole waiting to be used", consoleRailRing.Why)
202 }
203 })
204 }
205}
206