| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 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. |
| 39 | func 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. |
| 80 | func 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/. |
| 93 | func 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. |
| 108 | func 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. |
| 117 | func 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. |
| 148 | func 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 | |