| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 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 | |
| 51 | func 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. |
| 62 | func 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 | |
| 96 | func 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. |
| 110 | func 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 | |
| 120 | func 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. |
| 133 | func 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. |
| 149 | const matrixMargin = 8 |
| 150 | |
| 151 | // samePixels reports whether two PNGs agree within the tolerance above. |
| 152 | func 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. |
| 162 | func 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. |
| 205 | var 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. |
| 264 | var matrixStates = [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}} |
| 265 | |
| 266 | // matrixKey names one crop: scheme, the pinned states and the target. |
| 267 | func 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. |
| 278 | func 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. |
| 287 | func 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. |
| 341 | func 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 | |