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 "bytes"
7 "context"
8 "fmt"
9 "image"
10 "image/draw"
11 "image/png"
12 "math"
13 "net/http"
14 "os"
15 "sort"
16 "strings"
17 "testing"
18 "time"
19
20 "github.com/chromedp/chromedp"
21
22 "amadan.net/rastrillo/fiesta"
23 "amadan.net/rastrillo/fiesta/internal/harness"
24)
25
26// The two promises this file holds, pixel for pixel: an app that takes
27// the new tokens.css and keeps its old theme renders as it did,
28// and plain and signal render as they did. The page is the
29// depth fixture; it is photographed once per state set (rest, hovered,
30// pressed, focused and current) under the v0.28 stylesheet pair and
31// under the new one, and every component is cropped out of each shot
32// with a margin for its shadow; the two crops must agree. Antialiasing
33// at a rounded corner differs where a hairline is now painted by a
34// gradient instead of a border colour: Chromium antialiases the
35// inner corners of the padding-box clip under a transparent border
36// differently from an opaque border's inner edge, measured at 9 to 16
37// units on 2 to 10 pixels per card, in every theme, with the straight
38// edges and interiors identical. So the comparison allows a per-channel
39// delta of 24 (the measured 16 with headroom for drift between Chromium
40// builds) on at most 1% of a crop's pixels and nothing more; the count
41// rule is what catches a faint tint across a whole component, and a cap
42// of 8 failed every card on its corners. Per crop, not per page: on the
43// 1280x1600 page 1% was twenty thousand pixels, and a 3% tint over a
44// whole hovered button passed under it; on a 90x34 crop it is a corner's
45// worth, and the second negative control holds it there.
46//
47// The server pairs a stylesheet set with a directory: /old/ serves the
48// snapshots, /new/ the library, and the page links its stylesheets
49// relatively so each directory resolves its own.
50
51func matrixDoc() string {
52 return strings.Replace(strings.Replace(sizingDoc("matrix", depthBody), `href="/tokens.css"`, `href="tokens.css"`, 1), `href="/theme.css"`, `href="theme.css"`, 1)
53}
54
55// legacyRig serves /old/ and /new/ with their own stylesheet bytes in
56// front of sizingMux. The two routes are matched here, not registered
57// on the mux: stylesheets() already owns GET /tokens.css and
58// GET /theme.css there, and a second registration panics. Anything in
59// head goes after the theme link on both pages: an app's own
60// stylesheet, for the drive that holds a flat theme to an app's
61// overrides.
62func legacyRig(t *testing.T, oldTokens, oldTheme, newTokens, newTheme []byte, withLight bool, head ...string) *harness.Rig {
63 t.Helper()
64 doc := matrixDoc()
65 if withLight {
66 doc = strings.Replace(doc, `<script defer src="/busy.js"></script>`, `<script defer src="/busy.js"></script><script defer src="/light.js"></script>`, 1)
67 }
68 if len(head) > 0 {
69 const link = `<link rel="stylesheet" href="theme.css">`
70 if !strings.Contains(doc, link) {
71 t.Fatalf("the matrix page has no %s to put an app stylesheet after", link)
72 }
73 doc = strings.Replace(doc, link, link+strings.Join(head, ""), 1)
74 }
75 pages := map[string]string{"/old/": doc, "/new/": doc}
76 return harness.New(t, func(string) http.Handler {
77 mux := sizingMux(t, pages)
78 sets := map[string][2][]byte{"/old/": {oldTokens, oldTheme}, "/new/": {newTokens, newTheme}}
79 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80 for prefix, set := range sets {
81 if r.URL.Path == prefix+"tokens.css" || r.URL.Path == prefix+"theme.css" {
82 w.Header().Set("Content-Type", "text/css")
83 if strings.HasSuffix(r.URL.Path, "tokens.css") {
84 w.Write(set[0])
85 } else {
86 w.Write(set[1])
87 }
88 return
89 }
90 }
91 mux.ServeHTTP(w, r)
92 })
93 }, harness.WithHover())
94}
95
96func legacyFile(t *testing.T, name string) []byte {
97 t.Helper()
98 b, err := os.ReadFile("testdata/legacy/" + name)
99 if err != nil {
100 t.Fatal(err)
101 }
102 return b
103}
104
105// snap photographs one element in one forced state (the light.js
106// old-theme drive uses it): a full-page shot cropped to the
107// element's box. Not chromedp.Screenshot(selector): on this Chromium it
108// scrolled, waited and never returned, which is what timed the first
109// matrix out.
110func snap(t *testing.T, ctx context.Context, selector string, states ...string) []byte {
111 t.Helper()
112 forceState(t, ctx, selector, states...)
113 x, y, w, h := rect(t, ctx, selector)
114 var buf []byte
115 mustRun(t, ctx, chromedp.FullScreenshot(&buf, 100))
116 forceState(t, ctx, selector)
117 return cropPNG(t, decodePNG(t, buf), x, y, w, h, 0)
118}
119
120func decodePNG(t *testing.T, b []byte) image.Image {
121 t.Helper()
122 img, err := png.Decode(bytes.NewReader(b))
123 if err != nil {
124 t.Fatal(err)
125 }
126 return img
127}
128
129// cropPNG cuts one element's box out of a page shot, grown by margin on
130// every side and clipped to the page, and encodes it. The box is
131// floored and ceiled to whole pixels so a fractional edge keeps the
132// antialiased column it lands on.
133func cropPNG(t *testing.T, img image.Image, x, y, w, h float64, margin int) []byte {
134 t.Helper()
135 r := image.Rect(int(math.Floor(x))-margin, int(math.Floor(y))-margin, int(math.Ceil(x+w))+margin, int(math.Ceil(y+h))+margin).Intersect(img.Bounds())
136 crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
137 draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
138 var out bytes.Buffer
139 if err := png.Encode(&out, crop); err != nil {
140 t.Fatal(err)
141 }
142 return out.Bytes()
143}
144
145// matrixMargin is how far past a target's box its crop reaches: the
146// ambient shadow is offset by up to 3px and blurred by 3px, and a card's
147// later rim is drawn inside its box, so 8px covers every painted pixel
148// a state can add outside the element.
149const matrixMargin = 8
150
151// samePixels reports whether two PNGs agree within the tolerance above.
152func samePixels(t *testing.T, a, b []byte) (bool, string) {
153 t.Helper()
154 return samePixelsWithin(t, a, b, 24)
155}
156
157// samePixelsWithin is samePixels with the per-channel cap chosen by the
158// caller; the 1% count rule is the same. The app-override drive needs
159// a higher cap: the corner antialiasing above scales with how far the
160// hairline sits from the surface, and an app's hairline can sit
161// further than the stock pair's.
162func samePixelsWithin(t *testing.T, a, b []byte, maxDelta int) (bool, string) {
163 t.Helper()
164 ia, err := png.Decode(bytes.NewReader(a))
165 if err != nil {
166 t.Fatal(err)
167 }
168 ib, err := png.Decode(bytes.NewReader(b))
169 if err != nil {
170 t.Fatal(err)
171 }
172 if ia.Bounds() != ib.Bounds() {
173 return false, fmt.Sprintf("sizes differ: %v vs %v", ia.Bounds(), ib.Bounds())
174 }
175 bad, total := 0, 0
176 for y := ia.Bounds().Min.Y; y < ia.Bounds().Max.Y; y++ {
177 for x := ia.Bounds().Min.X; x < ia.Bounds().Max.X; x++ {
178 ra, ga, ba, _ := ia.At(x, y).RGBA()
179 rb, gb, bb, _ := ib.At(x, y).RGBA()
180 total++
181 d := func(p, q uint32) int {
182 v := int(p>>8) - int(q>>8)
183 if v < 0 {
184 v = -v
185 }
186 return v
187 }
188 if m := max(d(ra, rb), d(ga, gb), d(ba, bb)); m > maxDelta {
189 return false, fmt.Sprintf("pixel %d,%d differs by %d", x, y, m)
190 } else if m > 0 {
191 bad++
192 }
193 }
194 }
195 if float64(bad) > float64(total)*0.01 {
196 return false, fmt.Sprintf("%d of %d pixels differ", bad, total)
197 }
198 return true, ""
199}
200
201// matrixTargets is every component and every state the depth language
202// touches, in both spellings where the fixture has both. force is the
203// element the state is pinned on when it is not the one photographed:
204// a row's overlay link going active tints the row.
205var matrixTargets = []struct {
206 selector, force string
207 states [][]string
208}{
209 {"#b-plain", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}}},
210 {"#b-plain-c", "", [][]string{{}, {"hover"}, {"hover", "active"}}},
211 {"#b-primary", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}}},
212 {"#b-primary-c", "", [][]string{{}, {"hover"}, {"hover", "active"}}},
213 {"#b-danger", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}}},
214 {"#b-ghost", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}}},
215 {"#b-link", "", [][]string{{}, {"hover"}, {"hover", "active"}}},
216 {"#b-off", "", [][]string{{}, {"hover"}}},
217 {"#box", "", [][]string{{}}},
218 {"#stats", "", [][]string{{}}},
219 {"#list", "", [][]string{{}}},
220 {"#lra", "", [][]string{{}, {"hover"}}},
221 {"#lra", "#lra-link", [][]string{{"active"}}},
222 {"#card", "", [][]string{{}}},
223 {"#row-a", "", [][]string{{}, {"hover"}}},
224 {"#row-a", "#link-a", [][]string{{"active"}}},
225 {"#row-c", "", [][]string{{}, {"hover"}}},
226 {"#row-inert", "", [][]string{{}, {"hover"}}},
227 {"#tile", "", [][]string{{}, {"hover"}}},
228 {"#tile", "#tile-link", [][]string{{"active"}}},
229 {"#sidebar", "", [][]string{{}}},
230 {"#nav-x", "", [][]string{{"hover"}, {"hover", "active"}, {"active"}}},
231 {"#console", "", [][]string{{}}},
232 {"#topbar", "", [][]string{{}}},
233 {"#tabs", "", [][]string{{}}},
234 {"#tab-1", "", [][]string{{"hover"}, {"hover", "active"}, {"active"}}},
235 {"#pages", "", [][]string{{}}},
236 {"#page-1", "", [][]string{{"hover"}, {"hover", "active"}, {"active"}}},
237 // Focused too: the outline follows border-radius, so a radius the
238 // face brings rounds the focus ring of a theme that has no face.
239 {"#dd-sum", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}}},
240 {"#sw", "", [][]string{{}, {"hover"}}},
241 {"#field", "", [][]string{{}, {"hover"}, {"focus", "focus-visible"}}},
242 // The on switch and the tblock: the knob, the track and the hover
243 // sheen all read new tokens, and a flat theme's must paint nothing.
244 {"#sw-on", "", [][]string{{}, {"hover"}}},
245 {"#tb", "", [][]string{{}}},
246 {"#tb", "#tb-head", [][]string{{"hover"}}},
247 // The other two fields: the inset shadow and the focus halo. The
248 // search box shows focus on :focus-within, pinned on its input.
249 {"#area", "", [][]string{{}, {"focus", "focus-visible"}}},
250 {"#search", "", [][]string{{}}},
251 {"#search", "#search-in", [][]string{{"focus", "focus-visible"}}},
252 {"#dd-menu", "", [][]string{{}}},
253 {"#dd-nested-sum", "", [][]string{{}, {"hover"}, {"hover", "active"}}},
254 {"#menu-a", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}}},
255 {"#menu-btn", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}}},
256 {"#opt-active", "", [][]string{{}}},
257 // An index-view root at desktop width is the rail beside the page
258 // (the scaffold's home page): its items hover and press like any rail's.
259 {"#isidebar", "", [][]string{{}}},
260 {"#inav-x", "", [][]string{{"hover"}, {"hover", "active"}, {"active"}}},
261}
262
263// matrixStates is every state set the targets name, in a fixed order.
264var matrixStates = [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}}
265
266// matrixKey names one crop: scheme, the pinned states and the target.
267func matrixKey(scheme string, states []string, selector string) string {
268 return scheme + " " + strings.Join(states, "+") + " " + selector
269}
270
271// matrixShots photographs the whole page once per state set under one
272// stylesheet pair and scheme, with the state pinned on every target that
273// lists it at the same time, and returns each target's crop keyed by
274// matrixKey. One full-page shot per state set rather than one shot per
275// element: the per-element version took over ten minutes for the five
276// pairs, and the pixels are the same pixels. The crops are compared one
277// by one so the 1% allowance is a component's, not the page's.
278func matrixShots(t *testing.T, ctx context.Context, origin, dir, scheme string) map[string][]byte {
279 t.Helper()
280 shots, _ := matrixShotsExcept(t, ctx, origin, dir, scheme, nil)
281 return shots
282}
283
284// matrixShotsExcept is matrixShots with the areas an exception covers
285// blanked in every shot before it is cropped, and those areas returned,
286// so the caller can hold them to the same place on both sides.
287func matrixShotsExcept(t *testing.T, ctx context.Context, origin, dir, scheme string, exception *renderingException) (map[string][]byte, []exceptedArea) {
288 t.Helper()
289 mustRun(t, ctx, chromedp.EmulateViewport(1280, 1600), chromedp.Navigate(origin+dir), chromedp.WaitVisible("#field", chromedp.ByQuery),
290 chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), document.getElementById("dd").open = true, true`, nil))
291 still(t, ctx)
292 var areas []exceptedArea
293 if exception != nil {
294 areas = exception.areas(t, ctx)
295 }
296 out := map[string][]byte{}
297 for _, states := range matrixStates {
298 var pinned []string
299 type box struct {
300 key string
301 x, y, w, h float64
302 }
303 var boxes []box
304 for _, target := range matrixTargets {
305 for _, s := range target.states {
306 if strings.Join(s, "+") == strings.Join(states, "+") {
307 force := target.force
308 if force == "" {
309 force = target.selector
310 }
311 forceState(t, ctx, force, states...)
312 pinned = append(pinned, force)
313 // The box is read with the state pinned, so a state that
314 // moves the element (a press drop) crops where it is.
315 x, y, w, h := rect(t, ctx, target.selector)
316 boxes = append(boxes, box{matrixKey(scheme, states, target.selector), x, y, w, h})
317 }
318 }
319 }
320 var buf []byte
321 mustRun(t, ctx, chromedp.FullScreenshot(&buf, 100))
322 img := blanked(decodePNG(t, buf), areas)
323 for _, b := range boxes {
324 out[b.key] = cropPNG(t, img, b.x, b.y, b.w, b.h, matrixMargin)
325 }
326 for _, force := range pinned {
327 forceState(t, ctx, force)
328 }
329 }
330 return out, areas
331}
332
333// TestEveryThemeRendersAsItDidExceptDay is a preservation test: it does
334// not fail before its change, it fails if any later change breaks the
335// two promises. It runs the matrix in both schemes for three pairs (new
336// tokens with the old day, plain and signal) against the old tokens
337// with the same old theme, and for plain and signal also the new tokens
338// with the NEW theme against the old pair: those two must not change at
339// all. Its sensitivity is proved by the negative control at the
340// end, which breaks compatibility on purpose and must be caught.
341func TestEveryThemeRendersAsItDidExceptDay(t *testing.T) {
342 oldTokens := legacyFile(t, "tokens-v028.css")
343 // head is an app stylesheet after the theme on both pages: the
344 // square-pill case is an old custom theme that sets --rst-radius-pill
345 // to 0 (its chips and tabs square), which must not square its
346 // switch, whose 10px radius never read that token.
347 for _, c := range []struct{ name, oldTheme, newTheme, head string }{
348 {"old day theme", "day-v028.css", "", ""},
349 {"old day theme, square pill", "day-v028.css", "", "<style>:root { --rst-radius-pill: 0; }</style>"},
350 {"old plain theme", "plain-v028.css", "", ""},
351 {"old signal theme", "signal-v028.css", "", ""},
352 {"plain", "plain-v028.css", "plain", ""},
353 {"signal", "signal-v028.css", "signal", ""},
354 } {
355 t.Run(c.name, func(t *testing.T) {
356 oldTheme := legacyFile(t, c.oldTheme)
357 newTheme := oldTheme
358 if c.newTheme != "" {
359 newTheme, _ = fiesta.ThemeCSS(c.newTheme)
360 }
361 var head []string
362 if c.head != "" {
363 head = []string{c.head}
364 }
365 rig := legacyRig(t, oldTokens, oldTheme, fiesta.TokensCSS(), newTheme, false, head...)
366 ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second)
367 defer cancel()
368 for _, scheme := range []string{"light", "dark"} {
369 old := matrixShots(t, ctx, rig.Origin, "/old/", scheme)
370 now := matrixShots(t, ctx, rig.Origin, "/new/", scheme)
371 keys := make([]string, 0, len(old))
372 for key := range old {
373 keys = append(keys, key)
374 }
375 sort.Strings(keys)
376 for _, key := range keys {
377 if ok, why := samePixels(t, old[key], now[key]); !ok {
378 t.Errorf("%s renders differently under the new tokens.css: %s", key, why)
379 }
380 }
381 }
382 })
383 }
384 t.Run("negative control", func(t *testing.T) {
385 // The old day theme plus one new token painted red: the new
386 // tokens.css reads it, the old did not, and the matrix must see
387 // the difference, or every pass above proves nothing.
388 oldTheme := legacyFile(t, "day-v028.css")
389 broken := append(append([]byte{}, oldTheme...), []byte("\n:root { --rst-accent-fill: #ff0000; }\n")...)
390 rig := legacyRig(t, oldTokens, oldTheme, fiesta.TokensCSS(), broken, false)
391 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
392 defer cancel()
393 old := matrixShots(t, ctx, rig.Origin, "/old/", "light")
394 now := matrixShots(t, ctx, rig.Origin, "/new/", "light")
395 key := matrixKey("light", nil, "#b-primary")
396 if ok, _ := samePixels(t, old[key], now[key]); ok {
397 t.Fatal("a primary button filled red passed the matrix; the comparison is blind")
398 }
399 })
400 t.Run("negative control, faint tint", func(t *testing.T) {
401 // The old day theme under a tokens.css whose ghost hover fallback
402 // is a 3% black tint in place of transparent: one component, one
403 // state, a few hundred pixels each off by 7 or 8. The matrix must
404 // see it, or a faint tint on a whole component is a free change.
405 oldTheme := legacyFile(t, "day-v028.css")
406 tinted := bytes.Replace(fiesta.TokensCSS(), []byte("var(--rst-ghost-face-hover, transparent)"), []byte("var(--rst-ghost-face-hover, rgb(0 0 0 / 0.03))"), 1)
407 if bytes.Equal(tinted, fiesta.TokensCSS()) {
408 t.Fatal("the ghost hover fallback is not where this control expects it; the tint was not applied")
409 }
410 rig := legacyRig(t, oldTokens, oldTheme, tinted, oldTheme, false)
411 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
412 defer cancel()
413 old := matrixShots(t, ctx, rig.Origin, "/old/", "light")
414 now := matrixShots(t, ctx, rig.Origin, "/new/", "light")
415 key := matrixKey("light", []string{"hover"}, "#b-ghost")
416 if ok, _ := samePixels(t, old[key], now[key]); ok {
417 t.Fatal("a ghost button tinted 3% on hover passed the matrix; a whole component can change under the allowance")
418 }
419 })
420}
421