//go:build browser package check import ( "context" "fmt" "net/http" "os" "sort" "strings" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" ) // The parity drive holds 1.0.0's appearance across 1.x: the depth // fixture's matrix and 1.0.0's seven layouts, under 1.0.0's CSS // (testdata/seed/) and under the CSS this tree ships (css/), must agree // to the pixel. It says which theme, which cell and which page moved // when css/ does. The layouts are 1.0.0's own documents, from // testdata/seed/examples/: the tree's current layouts are 1.x markup, // and photographing them under CSS that never styled them compares // nothing a 1.0 site has. // What a still photograph cannot see is shell.css's own content: the // phone-width cross-document view transitions, which pageShot skips // before every shot. The drive holds shell.css to what it paints on a // settled page, which today is nothing; the slide itself is not under // this gate. // // parityRig is legacyRig's shape for a whole asset set: two copies of // the same documents at /old/ and /new/, each beside its // own tokens.css, theme.css, shell.css and scripts, which the documents // link relatively (parityLinks), so each side resolves its own. func parityRig(t *testing.T, docs map[string]string, oldAssets, newAssets map[string][]byte) *harness.Rig { t.Helper() sides := map[string]map[string][]byte{"old": oldAssets, "new": newAssets} return harness.New(t, func(string) http.Handler { mux := http.NewServeMux() fixtureImages(mux) // One literal route per side rather than /{side}/{file}: Go's // mux calls that and /images/ a conflict, since neither is the // more specific. for side, assets := range sides { assets := assets mux.HandleFunc("GET /"+side+"/{file}", func(w http.ResponseWriter, r *http.Request) { file := r.PathValue("file") if doc, ok := docs[file]; ok { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, doc) return } body, ok := assets[file] if !ok { http.NotFound(w, r) return } switch { case strings.HasSuffix(file, ".css"): w.Header().Set("Content-Type", "text/css") case strings.HasSuffix(file, ".js"): w.Header().Set("Content-Type", "text/javascript") } w.Write(body) }) } return mux }, harness.WithHover(), harness.WithDeterministicRaster()) } // parityAssets is one side's asset set: the three stylesheets under the // flat names the documents link, and every shipped script. The scripts // are the tree's on both sides: there is no seed copy of js/, and the // scripts are not under the baseline, so the drive compares the CSS // with the scripts held equal. func parityAssets(tokens, theme, shell []byte) map[string][]byte { assets := map[string][]byte{"tokens.css": tokens, "theme.css": theme, "shell.css": shell} for _, name := range fiesta.ScriptNames() { js, ok := fiesta.Script(name) if !ok { panic("fiesta: " + name + " is named but not shipped") } assets[name] = js } return assets } // seedFile reads one file of the synced baseline, testdata/seed/. func seedFile(t *testing.T, name string) []byte { t.Helper() b, err := os.ReadFile("testdata/seed/" + name) if err != nil { t.Fatal(err) } return b } // pageShot photographs one whole page at one width in one scheme, // frozen: the viewport is set before the navigation (a change after // it turns the hover query off in this Chromium), a cross-document // view transition the shells run is skipped and waited out, the scheme // is pinned on the root, and still() stops every transition and holds // the light straight above before the shot. func pageShot(t *testing.T, ctx context.Context, url string, width int64, scheme string) []byte { t.Helper() return pageShotChecked(t, ctx, url, width, scheme, nil) } // pageShotChecked is pageShot with check run on the settled page before // it is frozen and photographed: the 1.0 photographs hold every picture // loaded and every request answered there, so two identical broken // pages cannot pass as two identical pages. func pageShotChecked(t *testing.T, ctx context.Context, url string, width int64, scheme string, check func()) []byte { t.Helper() height := int64(900) if width < 800 { height = 844 } mustRun(t, ctx, chromedp.EmulateViewport(width, height), chromedp.Navigate(url), chromedp.WaitReady("body", chromedp.ByQuery), 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), chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil)) if check != nil { check() } still(t, ctx) // light.js writes the held light in its next animation frame, not // at the event, so a shot taken at once sometimes caught a button's // sheen before the hold: two shots of one page under one stylesheet // differed by a pixel, 3 pairs in 18. Two frames later it is painted. mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, awaitPromise)) var buf []byte mustRun(t, ctx, chromedp.FullScreenshot(&buf, 100)) return buf } // TestEveryThemeAndShellRendersAsTheBaseline photographs the matrix // targets on the depth fixture and 1.0.0's seven layout documents at // 1280px and 390px, under the baseline CSS and under the tree's, across // the three themes and both schemes, and holds every crop and every // page to the pixel. One rig per theme, since the theme is an asset of // the rig; the scheme is pinned on each page. Adapted preservation: the // layouts are read from testdata/seed/examples/ rather than the tree's // examples, and fewer than seven fails. func TestEveryThemeAndShellRendersAsTheBaseline(t *testing.T) { docs := map[string]string{"depth": parityLinks(sizingDoc("depth", depthBody))} layouts := oneOhLayouts(t) for _, layout := range layouts { docs[layout] = parityLinks(string(seedFile(t, "examples/"+layout+"/default.html"))) } for _, theme := range fiesta.ThemeNames() { t.Run(theme, func(t *testing.T) { themeCSS, ok := fiesta.ThemeCSS(theme) if !ok { t.Fatalf("no theme %q", theme) } old := parityAssets(seedFile(t, "tokens.css"), seedFile(t, theme+".css"), seedFile(t, "shell.css")) now := parityAssets(fiesta.TokensCSS(), themeCSS, fiesta.ShellCSS()) rig := parityRig(t, docs, old, now) ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) defer cancel() covered := false for _, scheme := range []string{"light", "dark"} { was, wasAreas := matrixShotsExcept(t, ctx, rig.Origin, "/old/depth", scheme, consoleRailRing) is, isAreas := matrixShotsExcept(t, ctx, rig.Origin, "/new/depth", scheme, consoleRailRing) if why := sameAreas(wasAreas, isAreas); why != "" { t.Errorf("%s: the depth fixture in %s: %s", theme, scheme, why) } keys := make([]string, 0, len(was)) for key := range was { keys = append(keys, key) } sort.Strings(keys) if len(keys) == 0 { t.Fatalf("%s: the matrix photographed nothing in %s", theme, scheme) } for _, key := range keys { if ok, why := samePixelsWithin(t, was[key], is[key], 0); !ok { t.Errorf("%s: %s renders differently from the baseline: %s", theme, key, why) } } for _, layout := range layouts { for _, width := range []int64{1280, 390} { before, beforeAreas := pageShotExcept(t, ctx, rig.Origin+"/old/"+layout, width, scheme, consoleRailRing) after, afterAreas := pageShotExcept(t, ctx, rig.Origin+"/new/"+layout, width, scheme, consoleRailRing) ok, why, changed := samePixelsExcept(t, before, after, beforeAreas, afterAreas) if !ok { t.Errorf("%s: %s at %dpx in %s renders differently from the baseline: %s", theme, layout, width, scheme, why) } covered = covered || changed } } } // Day draws the ring and its shadow, so a Day run where the // exception covered no change is one where the fix is gone // or the exception has drifted off it. if theme == "day" && !covered { 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) } }) } }