//go:build browser
package check
import (
"bytes"
"context"
"fmt"
"image"
"image/draw"
"image/png"
"math"
"net/http"
"os"
"sort"
"strings"
"testing"
"time"
"github.com/chromedp/chromedp"
"amadan.net/rastrillo/fiesta"
"amadan.net/rastrillo/fiesta/internal/harness"
)
// The two promises this file holds, pixel for pixel: an app that takes
// the new tokens.css and keeps its old theme renders as it did,
// and plain and signal render as they did. The page is the
// depth fixture; it is photographed once per state set (rest, hovered,
// pressed, focused and current) under the v0.28 stylesheet pair and
// under the new one, and every component is cropped out of each shot
// with a margin for its shadow; the two crops must agree. Antialiasing
// at a rounded corner differs where a hairline is now painted by a
// gradient instead of a border colour: Chromium antialiases the
// inner corners of the padding-box clip under a transparent border
// differently from an opaque border's inner edge, measured at 9 to 16
// units on 2 to 10 pixels per card, in every theme, with the straight
// edges and interiors identical. So the comparison allows a per-channel
// delta of 24 (the measured 16 with headroom for drift between Chromium
// builds) on at most 1% of a crop's pixels and nothing more; the count
// rule is what catches a faint tint across a whole component, and a cap
// of 8 failed every card on its corners. Per crop, not per page: on the
// 1280x1600 page 1% was twenty thousand pixels, and a 3% tint over a
// whole hovered button passed under it; on a 90x34 crop it is a corner's
// worth, and the second negative control holds it there.
//
// The server pairs a stylesheet set with a directory: /old/ serves the
// snapshots, /new/ the library, and the page links its stylesheets
// relatively so each directory resolves its own.
func matrixDoc() string {
return strings.Replace(strings.Replace(sizingDoc("matrix", depthBody), `href="/tokens.css"`, `href="tokens.css"`, 1), `href="/theme.css"`, `href="theme.css"`, 1)
}
// legacyRig serves /old/ and /new/ with their own stylesheet bytes in
// front of sizingMux. The two routes are matched here, not registered
// on the mux: stylesheets() already owns GET /tokens.css and
// GET /theme.css there, and a second registration panics. Anything in
// head goes after the theme link on both pages: an app's own
// stylesheet, for the drive that holds a flat theme to an app's
// overrides.
func legacyRig(t *testing.T, oldTokens, oldTheme, newTokens, newTheme []byte, withLight bool, head ...string) *harness.Rig {
t.Helper()
doc := matrixDoc()
if withLight {
doc = strings.Replace(doc, ``, ``, 1)
}
if len(head) > 0 {
const link = ``
if !strings.Contains(doc, link) {
t.Fatalf("the matrix page has no %s to put an app stylesheet after", link)
}
doc = strings.Replace(doc, link, link+strings.Join(head, ""), 1)
}
pages := map[string]string{"/old/": doc, "/new/": doc}
return harness.New(t, func(string) http.Handler {
mux := sizingMux(t, pages)
sets := map[string][2][]byte{"/old/": {oldTokens, oldTheme}, "/new/": {newTokens, newTheme}}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for prefix, set := range sets {
if r.URL.Path == prefix+"tokens.css" || r.URL.Path == prefix+"theme.css" {
w.Header().Set("Content-Type", "text/css")
if strings.HasSuffix(r.URL.Path, "tokens.css") {
w.Write(set[0])
} else {
w.Write(set[1])
}
return
}
}
mux.ServeHTTP(w, r)
})
}, harness.WithHover())
}
func legacyFile(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile("testdata/legacy/" + name)
if err != nil {
t.Fatal(err)
}
return b
}
// snap photographs one element in one forced state (the light.js
// old-theme drive uses it): a full-page shot cropped to the
// element's box. Not chromedp.Screenshot(selector): on this Chromium it
// scrolled, waited and never returned, which is what timed the first
// matrix out.
func snap(t *testing.T, ctx context.Context, selector string, states ...string) []byte {
t.Helper()
forceState(t, ctx, selector, states...)
x, y, w, h := rect(t, ctx, selector)
var buf []byte
mustRun(t, ctx, chromedp.FullScreenshot(&buf, 100))
forceState(t, ctx, selector)
return cropPNG(t, decodePNG(t, buf), x, y, w, h, 0)
}
func decodePNG(t *testing.T, b []byte) image.Image {
t.Helper()
img, err := png.Decode(bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
return img
}
// cropPNG cuts one element's box out of a page shot, grown by margin on
// every side and clipped to the page, and encodes it. The box is
// floored and ceiled to whole pixels so a fractional edge keeps the
// antialiased column it lands on.
func cropPNG(t *testing.T, img image.Image, x, y, w, h float64, margin int) []byte {
t.Helper()
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())
crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
var out bytes.Buffer
if err := png.Encode(&out, crop); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
// matrixMargin is how far past a target's box its crop reaches: the
// ambient shadow is offset by up to 3px and blurred by 3px, and a card's
// later rim is drawn inside its box, so 8px covers every painted pixel
// a state can add outside the element.
const matrixMargin = 8
// samePixels reports whether two PNGs agree within the tolerance above.
func samePixels(t *testing.T, a, b []byte) (bool, string) {
t.Helper()
return samePixelsWithin(t, a, b, 24)
}
// samePixelsWithin is samePixels with the per-channel cap chosen by the
// caller; the 1% count rule is the same. The app-override drive needs
// a higher cap: the corner antialiasing above scales with how far the
// hairline sits from the surface, and an app's hairline can sit
// further than the stock pair's.
func samePixelsWithin(t *testing.T, a, b []byte, maxDelta int) (bool, string) {
t.Helper()
ia, err := png.Decode(bytes.NewReader(a))
if err != nil {
t.Fatal(err)
}
ib, err := png.Decode(bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
if ia.Bounds() != ib.Bounds() {
return false, fmt.Sprintf("sizes differ: %v vs %v", ia.Bounds(), ib.Bounds())
}
bad, total := 0, 0
for y := ia.Bounds().Min.Y; y < ia.Bounds().Max.Y; y++ {
for x := ia.Bounds().Min.X; x < ia.Bounds().Max.X; x++ {
ra, ga, ba, _ := ia.At(x, y).RGBA()
rb, gb, bb, _ := ib.At(x, y).RGBA()
total++
d := func(p, q uint32) int {
v := int(p>>8) - int(q>>8)
if v < 0 {
v = -v
}
return v
}
if m := max(d(ra, rb), d(ga, gb), d(ba, bb)); m > maxDelta {
return false, fmt.Sprintf("pixel %d,%d differs by %d", x, y, m)
} else if m > 0 {
bad++
}
}
}
if float64(bad) > float64(total)*0.01 {
return false, fmt.Sprintf("%d of %d pixels differ", bad, total)
}
return true, ""
}
// matrixTargets is every component and every state the depth language
// touches, in both spellings where the fixture has both. force is the
// element the state is pinned on when it is not the one photographed:
// a row's overlay link going active tints the row.
var matrixTargets = []struct {
selector, force string
states [][]string
}{
{"#b-plain", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}}},
{"#b-plain-c", "", [][]string{{}, {"hover"}, {"hover", "active"}}},
{"#b-primary", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}}},
{"#b-primary-c", "", [][]string{{}, {"hover"}, {"hover", "active"}}},
{"#b-danger", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}}},
{"#b-ghost", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}}},
{"#b-link", "", [][]string{{}, {"hover"}, {"hover", "active"}}},
{"#b-off", "", [][]string{{}, {"hover"}}},
{"#box", "", [][]string{{}}},
{"#stats", "", [][]string{{}}},
{"#list", "", [][]string{{}}},
{"#lra", "", [][]string{{}, {"hover"}}},
{"#lra", "#lra-link", [][]string{{"active"}}},
{"#card", "", [][]string{{}}},
{"#row-a", "", [][]string{{}, {"hover"}}},
{"#row-a", "#link-a", [][]string{{"active"}}},
{"#row-c", "", [][]string{{}, {"hover"}}},
{"#row-inert", "", [][]string{{}, {"hover"}}},
{"#tile", "", [][]string{{}, {"hover"}}},
{"#tile", "#tile-link", [][]string{{"active"}}},
{"#sidebar", "", [][]string{{}}},
{"#nav-x", "", [][]string{{"hover"}, {"hover", "active"}, {"active"}}},
{"#console", "", [][]string{{}}},
{"#topbar", "", [][]string{{}}},
{"#tabs", "", [][]string{{}}},
{"#tab-1", "", [][]string{{"hover"}, {"hover", "active"}, {"active"}}},
{"#pages", "", [][]string{{}}},
{"#page-1", "", [][]string{{"hover"}, {"hover", "active"}, {"active"}}},
// Focused too: the outline follows border-radius, so a radius the
// face brings rounds the focus ring of a theme that has no face.
{"#dd-sum", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}}},
{"#sw", "", [][]string{{}, {"hover"}}},
{"#field", "", [][]string{{}, {"hover"}, {"focus", "focus-visible"}}},
// The on switch and the tblock: the knob, the track and the hover
// sheen all read new tokens, and a flat theme's must paint nothing.
{"#sw-on", "", [][]string{{}, {"hover"}}},
{"#tb", "", [][]string{{}}},
{"#tb", "#tb-head", [][]string{{"hover"}}},
// The other two fields: the inset shadow and the focus halo. The
// search box shows focus on :focus-within, pinned on its input.
{"#area", "", [][]string{{}, {"focus", "focus-visible"}}},
{"#search", "", [][]string{{}}},
{"#search", "#search-in", [][]string{{"focus", "focus-visible"}}},
{"#dd-menu", "", [][]string{{}}},
{"#dd-nested-sum", "", [][]string{{}, {"hover"}, {"hover", "active"}}},
{"#menu-a", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}}},
{"#menu-btn", "", [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}}},
{"#opt-active", "", [][]string{{}}},
// An index-view root at desktop width is the rail beside the page
// (the scaffold's home page): its items hover and press like any rail's.
{"#isidebar", "", [][]string{{}}},
{"#inav-x", "", [][]string{{"hover"}, {"hover", "active"}, {"active"}}},
}
// matrixStates is every state set the targets name, in a fixed order.
var matrixStates = [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}, {"focus", "focus-visible"}}
// matrixKey names one crop: scheme, the pinned states and the target.
func matrixKey(scheme string, states []string, selector string) string {
return scheme + " " + strings.Join(states, "+") + " " + selector
}
// matrixShots photographs the whole page once per state set under one
// stylesheet pair and scheme, with the state pinned on every target that
// lists it at the same time, and returns each target's crop keyed by
// matrixKey. One full-page shot per state set rather than one shot per
// element: the per-element version took over ten minutes for the five
// pairs, and the pixels are the same pixels. The crops are compared one
// by one so the 1% allowance is a component's, not the page's.
func matrixShots(t *testing.T, ctx context.Context, origin, dir, scheme string) map[string][]byte {
t.Helper()
shots, _ := matrixShotsExcept(t, ctx, origin, dir, scheme, nil)
return shots
}
// matrixShotsExcept is matrixShots with the areas an exception covers
// blanked in every shot before it is cropped, and those areas returned,
// so the caller can hold them to the same place on both sides.
func matrixShotsExcept(t *testing.T, ctx context.Context, origin, dir, scheme string, exception *renderingException) (map[string][]byte, []exceptedArea) {
t.Helper()
mustRun(t, ctx, chromedp.EmulateViewport(1280, 1600), chromedp.Navigate(origin+dir), chromedp.WaitVisible("#field", chromedp.ByQuery),
chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), document.getElementById("dd").open = true, true`, nil))
still(t, ctx)
var areas []exceptedArea
if exception != nil {
areas = exception.areas(t, ctx)
}
out := map[string][]byte{}
for _, states := range matrixStates {
var pinned []string
type box struct {
key string
x, y, w, h float64
}
var boxes []box
for _, target := range matrixTargets {
for _, s := range target.states {
if strings.Join(s, "+") == strings.Join(states, "+") {
force := target.force
if force == "" {
force = target.selector
}
forceState(t, ctx, force, states...)
pinned = append(pinned, force)
// The box is read with the state pinned, so a state that
// moves the element (a press drop) crops where it is.
x, y, w, h := rect(t, ctx, target.selector)
boxes = append(boxes, box{matrixKey(scheme, states, target.selector), x, y, w, h})
}
}
}
var buf []byte
mustRun(t, ctx, chromedp.FullScreenshot(&buf, 100))
img := blanked(decodePNG(t, buf), areas)
for _, b := range boxes {
out[b.key] = cropPNG(t, img, b.x, b.y, b.w, b.h, matrixMargin)
}
for _, force := range pinned {
forceState(t, ctx, force)
}
}
return out, areas
}
// TestEveryThemeRendersAsItDidExceptDay is a preservation test: it does
// not fail before its change, it fails if any later change breaks the
// two promises. It runs the matrix in both schemes for three pairs (new
// tokens with the old day, plain and signal) against the old tokens
// with the same old theme, and for plain and signal also the new tokens
// with the NEW theme against the old pair: those two must not change at
// all. Its sensitivity is proved by the negative control at the
// end, which breaks compatibility on purpose and must be caught.
func TestEveryThemeRendersAsItDidExceptDay(t *testing.T) {
oldTokens := legacyFile(t, "tokens-v028.css")
// head is an app stylesheet after the theme on both pages: the
// square-pill case is an old custom theme that sets --rst-radius-pill
// to 0 (its chips and tabs square), which must not square its
// switch, whose 10px radius never read that token.
for _, c := range []struct{ name, oldTheme, newTheme, head string }{
{"old day theme", "day-v028.css", "", ""},
{"old day theme, square pill", "day-v028.css", "", ""},
{"old plain theme", "plain-v028.css", "", ""},
{"old signal theme", "signal-v028.css", "", ""},
{"plain", "plain-v028.css", "plain", ""},
{"signal", "signal-v028.css", "signal", ""},
} {
t.Run(c.name, func(t *testing.T) {
oldTheme := legacyFile(t, c.oldTheme)
newTheme := oldTheme
if c.newTheme != "" {
newTheme, _ = fiesta.ThemeCSS(c.newTheme)
}
var head []string
if c.head != "" {
head = []string{c.head}
}
rig := legacyRig(t, oldTokens, oldTheme, fiesta.TokensCSS(), newTheme, false, head...)
ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second)
defer cancel()
for _, scheme := range []string{"light", "dark"} {
old := matrixShots(t, ctx, rig.Origin, "/old/", scheme)
now := matrixShots(t, ctx, rig.Origin, "/new/", scheme)
keys := make([]string, 0, len(old))
for key := range old {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if ok, why := samePixels(t, old[key], now[key]); !ok {
t.Errorf("%s renders differently under the new tokens.css: %s", key, why)
}
}
}
})
}
t.Run("negative control", func(t *testing.T) {
// The old day theme plus one new token painted red: the new
// tokens.css reads it, the old did not, and the matrix must see
// the difference, or every pass above proves nothing.
oldTheme := legacyFile(t, "day-v028.css")
broken := append(append([]byte{}, oldTheme...), []byte("\n:root { --rst-accent-fill: #ff0000; }\n")...)
rig := legacyRig(t, oldTokens, oldTheme, fiesta.TokensCSS(), broken, false)
ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
defer cancel()
old := matrixShots(t, ctx, rig.Origin, "/old/", "light")
now := matrixShots(t, ctx, rig.Origin, "/new/", "light")
key := matrixKey("light", nil, "#b-primary")
if ok, _ := samePixels(t, old[key], now[key]); ok {
t.Fatal("a primary button filled red passed the matrix; the comparison is blind")
}
})
t.Run("negative control, faint tint", func(t *testing.T) {
// The old day theme under a tokens.css whose ghost hover fallback
// is a 3% black tint in place of transparent: one component, one
// state, a few hundred pixels each off by 7 or 8. The matrix must
// see it, or a faint tint on a whole component is a free change.
oldTheme := legacyFile(t, "day-v028.css")
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)
if bytes.Equal(tinted, fiesta.TokensCSS()) {
t.Fatal("the ghost hover fallback is not where this control expects it; the tint was not applied")
}
rig := legacyRig(t, oldTokens, oldTheme, tinted, oldTheme, false)
ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
defer cancel()
old := matrixShots(t, ctx, rig.Origin, "/old/", "light")
now := matrixShots(t, ctx, rig.Origin, "/new/", "light")
key := matrixKey("light", []string{"hover"}, "#b-ghost")
if ok, _ := samePixels(t, old[key], now[key]); ok {
t.Fatal("a ghost button tinted 3% on hover passed the matrix; a whole component can change under the allowance")
}
})
}