| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "io/fs" |
| 10 | "net/http" |
| 11 | "os" |
| 12 | "path" |
| 13 | "path/filepath" |
| 14 | "slices" |
| 15 | "strings" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/chromedp/cdproto/browser" |
| 20 | "github.com/chromedp/cdproto/cdp" |
| 21 | "github.com/chromedp/cdproto/emulation" |
| 22 | "github.com/chromedp/cdproto/systeminfo" |
| 23 | "github.com/chromedp/chromedp" |
| 24 | |
| 25 | "amadan.net/rastrillo/fiesta" |
| 26 | "amadan.net/rastrillo/fiesta/example" |
| 27 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 28 | ) |
| 29 | |
| 30 | // The 1.0 photographs: 1.0.0's own examples, copied from the tag into |
| 31 | // testdata/seed/examples/, photographed under 1.0.0's CSS and under the |
| 32 | // tree's, pixel for pixel. A site that links /1/ gets each 1.x release |
| 33 | // the day it ships with no edit, so 1.0 markup must draw exactly as it |
| 34 | // did. The current examples would not do: they are 1.x markup, which |
| 35 | // 1.0.0's CSS never styled. The declaration gates (baseline_test.go) |
| 36 | // say what may change; these say what a reader sees did not, at the |
| 37 | // widths and in the media the declaration gates cannot see into. |
| 38 | |
| 39 | // rtlQuery asks oneOhTree for a document turned right to left: its |
| 40 | // <html lang="en" dir="ltr"> served as lang="ar" dir="rtl", so the |
| 41 | // page's scripts see the direction from the first byte, as they would |
| 42 | // on an Arabic page. |
| 43 | const rtlQuery = "dir=rtl" |
| 44 | |
| 45 | // oneOhTree serves one side of the comparison under /<side>/, laid out |
| 46 | // as the repository is, so the frozen documents' own relative links |
| 47 | // (../../css/tokens.css, ../../js/<file>, ../assets/<picture>) work |
| 48 | // unrewritten. css/tokens.css, css/shell.css and css/themes/day.css |
| 49 | // are the baseline's for old and the tree's for new; day.css carries |
| 50 | // the theme under test, so the documents need no rewriting to change |
| 51 | // theme. js/ is the tree's on both sides (TestOneOhScriptsAreUnchanged |
| 52 | // holds it to 1.0.0's), and examples/ is the frozen copy on both, |
| 53 | // pictures included, byte for byte. Anything else is a 404, which the |
| 54 | // rig reports, so a link the tree does not serve fails as itself. |
| 55 | func oneOhTree(t *testing.T, side, theme string) http.Handler { |
| 56 | t.Helper() |
| 57 | var tokens, shell, themeCSS []byte |
| 58 | switch side { |
| 59 | case "old": |
| 60 | tokens, shell, themeCSS = seedFile(t, "tokens.css"), seedFile(t, "shell.css"), seedFile(t, theme+".css") |
| 61 | case "new": |
| 62 | var ok bool |
| 63 | if themeCSS, ok = fiesta.ThemeCSS(theme); !ok { |
| 64 | t.Fatalf("no theme %q", theme) |
| 65 | } |
| 66 | tokens, shell = fiesta.TokensCSS(), fiesta.ShellCSS() |
| 67 | default: |
| 68 | t.Fatalf("oneOhTree: side %q is neither old nor new", side) |
| 69 | } |
| 70 | css := map[string][]byte{"css/tokens.css": tokens, "css/shell.css": shell, "css/themes/day.css": themeCSS} |
| 71 | seed := os.DirFS(filepath.Join("testdata", "seed")) |
| 72 | prefix := "/" + side + "/" |
| 73 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 74 | file, ok := strings.CutPrefix(r.URL.Path, prefix) |
| 75 | if !ok || !fs.ValidPath(file) { |
| 76 | http.NotFound(w, r) |
| 77 | return |
| 78 | } |
| 79 | if b, ok := css[file]; ok { |
| 80 | w.Header().Set("Content-Type", "text/css") |
| 81 | w.Write(b) |
| 82 | return |
| 83 | } |
| 84 | if name, ok := strings.CutPrefix(file, "js/"); ok { |
| 85 | b, ok := fiesta.Script(name) |
| 86 | if !ok { |
| 87 | http.NotFound(w, r) |
| 88 | return |
| 89 | } |
| 90 | w.Header().Set("Content-Type", "text/javascript") |
| 91 | w.Write(b) |
| 92 | return |
| 93 | } |
| 94 | if !strings.HasPrefix(file, "examples/") { |
| 95 | http.NotFound(w, r) |
| 96 | return |
| 97 | } |
| 98 | b, err := fs.ReadFile(seed, file) |
| 99 | if err != nil { |
| 100 | http.NotFound(w, r) |
| 101 | return |
| 102 | } |
| 103 | switch path.Ext(file) { |
| 104 | case ".html": |
| 105 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 106 | if r.URL.RawQuery == rtlQuery { |
| 107 | const ltr = `<html lang="en" dir="ltr"` |
| 108 | if !strings.Contains(string(b), ltr) { |
| 109 | http.Error(w, file+" does not open with "+ltr+", so it cannot be turned", http.StatusInternalServerError) |
| 110 | return |
| 111 | } |
| 112 | b = []byte(strings.Replace(string(b), ltr, `<html lang="ar" dir="rtl"`, 1)) |
| 113 | } |
| 114 | case ".svg": |
| 115 | w.Header().Set("Content-Type", "image/svg+xml") |
| 116 | } |
| 117 | w.Write(b) |
| 118 | }) |
| 119 | } |
| 120 | |
| 121 | // oneOhRig serves both sides for one theme, under one emulated medium |
| 122 | // when media is not nil, set before the first navigation as emulatedRig |
| 123 | // sets it. |
| 124 | func oneOhRig(t *testing.T, theme string, media *emulation.SetEmulatedMediaParams) (*harness.Rig, context.Context) { |
| 125 | t.Helper() |
| 126 | rig := harness.New(t, func(string) http.Handler { |
| 127 | mux := http.NewServeMux() |
| 128 | for _, side := range []string{"old", "new"} { |
| 129 | mux.Handle("/"+side+"/", oneOhTree(t, side, theme)) |
| 130 | } |
| 131 | return mux |
| 132 | }, harness.WithHover(), harness.WithDeterministicRaster()) |
| 133 | ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| 134 | t.Cleanup(cancel) |
| 135 | b := oneOhBrowserOf(t, ctx) |
| 136 | t.Logf("1.0 photographs on %s, %s (%s); launched with %s; GPU features %s", harness.ChromePath(t), b.product, b.revision, strings.Join(b.args, " "), b.features) |
| 137 | if media != nil { |
| 138 | mustRun(t, ctx, media) |
| 139 | } |
| 140 | return rig, ctx |
| 141 | } |
| 142 | |
| 143 | // oneOhBrowser is what the browser behind a 1.0 rig says it is: its |
| 144 | // product and revision, the arguments it was launched with, and its |
| 145 | // GPU feature status (chrome://gpu's table), which is where a raster |
| 146 | // flag the build ignored would show. |
| 147 | type oneOhBrowser struct { |
| 148 | product, revision string |
| 149 | args []string |
| 150 | features map[string]string |
| 151 | } |
| 152 | |
| 153 | func oneOhBrowserOf(t *testing.T, ctx context.Context) oneOhBrowser { |
| 154 | t.Helper() |
| 155 | var b oneOhBrowser |
| 156 | mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| 157 | bctx := cdp.WithExecutor(ctx, chromedp.FromContext(ctx).Browser) |
| 158 | var err error |
| 159 | if _, b.product, b.revision, _, _, err = browser.GetVersion().Do(bctx); err != nil { |
| 160 | return err |
| 161 | } |
| 162 | if b.args, err = browser.GetBrowserCommandLine().Do(bctx); err != nil { |
| 163 | return err |
| 164 | } |
| 165 | gpu, _, _, _, err := systeminfo.GetInfo().Do(bctx) |
| 166 | if err != nil { |
| 167 | return err |
| 168 | } |
| 169 | return json.Unmarshal(gpu.FeatureStatus, &b.features) |
| 170 | })) |
| 171 | return b |
| 172 | } |
| 173 | |
| 174 | // TestTheOneOhRigRastersInSoftware: the browser behind the 1.0 |
| 175 | // photographs took the raster flags and does not raster on a GPU. The |
| 176 | // photographs are only held to zero tolerance because two loads of |
| 177 | // one page paint alike in this configuration; a build that ignored a |
| 178 | // flag would bring back the one-level flake with no word of why. |
| 179 | func TestTheOneOhRigRastersInSoftware(t *testing.T) { |
| 180 | _, ctx := oneOhRig(t, "day", nil) |
| 181 | b := oneOhBrowserOf(t, ctx) |
| 182 | for _, flag := range []string{"--num-raster-threads=1", "--disable-gpu", "--disable-gpu-rasterization", "--disable-partial-raster", "--disable-lcd-text"} { |
| 183 | if !slices.Contains(b.args, flag) { |
| 184 | t.Errorf("%s was not launched with %s: %v", b.product, flag, b.args) |
| 185 | } |
| 186 | } |
| 187 | for _, feature := range []string{"rasterization", "multiple_raster_threads"} { |
| 188 | if v := b.features[feature]; v == "" || strings.HasPrefix(v, "enabled") { |
| 189 | t.Errorf("%s reports %s as %q; the 1.0 photographs want it off", b.product, feature, v) |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // oneOhLeg is one way of looking at a page. |
| 195 | type oneOhLeg struct { |
| 196 | width int64 |
| 197 | rtl bool |
| 198 | scheme string |
| 199 | media string // "screen", "forced colours" or "print" |
| 200 | } |
| 201 | |
| 202 | func (l oneOhLeg) String() string { |
| 203 | dir := "left to right" |
| 204 | if l.rtl { |
| 205 | dir = "right to left" |
| 206 | } |
| 207 | return fmt.Sprintf("%dpx, %s, %s, %s", l.width, dir, l.scheme, l.media) |
| 208 | } |
| 209 | |
| 210 | // imagesLoadedJS waits for every <img> on the page and lists those that |
| 211 | // did not load, so a missing picture fails as a missing picture rather |
| 212 | // than as two identical broken ones. It gives up after ten seconds and |
| 213 | // lists what is still waiting. |
| 214 | const imagesLoadedJS = `new Promise(r => { |
| 215 | const imgs = [...document.images]; |
| 216 | const done = () => r(imgs.filter(i => !(i.complete && i.naturalWidth > 0)).map(i => i.currentSrc || i.src)); |
| 217 | const timer = setTimeout(done, 10000); |
| 218 | Promise.all(imgs.map(i => i.complete ? null : new Promise(d => { i.addEventListener("load", d, {once: true}); i.addEventListener("error", d, {once: true}); }))) |
| 219 | .then(() => { clearTimeout(timer); done(); }); |
| 220 | })` |
| 221 | |
| 222 | // oneOhShotBudget is how long one photograph may take. A shot takes |
| 223 | // about a tenth of a second; the budget is there so a page or a |
| 224 | // browser that hangs fails as that shot, named, within a minute, |
| 225 | // rather than as the whole part running into its own deadline with no |
| 226 | // word of where it stopped (which is how a fleet run once read). |
| 227 | const oneOhShotBudget = 60 * time.Second |
| 228 | |
| 229 | // oneOhShot photographs one side's copy of a document in one leg, and |
| 230 | // reads the areas consoleRailRing covers on it. Before the photograph |
| 231 | // every picture has loaded and the rig has seen no failed request or |
| 232 | // response of 400 or more, or the test fails naming them. |
| 233 | func oneOhShot(t *testing.T, rig *harness.Rig, ctx context.Context, side, doc string, leg oneOhLeg) ([]byte, []exceptedArea) { |
| 234 | t.Helper() |
| 235 | url := rig.Origin + "/" + side + "/examples/" + doc |
| 236 | if leg.rtl { |
| 237 | url += "?" + rtlQuery |
| 238 | } |
| 239 | where := fmt.Sprintf("%s's %s at %s", side, doc, leg) |
| 240 | ctx, cancel := context.WithTimeout(ctx, oneOhShotBudget) |
| 241 | defer cancel() |
| 242 | // mustRun fails the test from inside the shot; this runs as that |
| 243 | // failure unwinds, and says which shot it was. |
| 244 | defer func() { |
| 245 | if ctx.Err() == context.DeadlineExceeded { |
| 246 | t.Errorf("%s: no answer within %s; the page or the browser hung", where, oneOhShotBudget) |
| 247 | } |
| 248 | }() |
| 249 | var areas []exceptedArea |
| 250 | shot := pageShotChecked(t, ctx, url, leg.width, leg.scheme, func() { |
| 251 | var broken []string |
| 252 | mustRun(t, ctx, chromedp.Evaluate(imagesLoadedJS, &broken, awaitPromise)) |
| 253 | if len(broken) > 0 { |
| 254 | t.Fatalf("%s: %d pictures did not load: %s", where, len(broken), strings.Join(broken, ", ")) |
| 255 | } |
| 256 | rig.Screen("body", where) |
| 257 | areas = consoleRailRing.areas(t, ctx) |
| 258 | }) |
| 259 | return shot, areas |
| 260 | } |
| 261 | |
| 262 | // sameOneOh photographs doc on both sides in each leg and fails each |
| 263 | // leg whose pictures differ by a single pixel outside consoleRailRing's |
| 264 | // areas, or whose areas moved. One pair is enough: the rig rasters on |
| 265 | // one thread in software (WithDeterministicRaster), under which 400 |
| 266 | // pairs of one page agreed under load; without it one state's callout |
| 267 | // corner came out one level apart in 4 of them. It reports whether the |
| 268 | // exception covered a change in any leg. |
| 269 | func sameOneOh(t *testing.T, rig *harness.Rig, ctx context.Context, doc string, legs []oneOhLeg) (covered bool) { |
| 270 | t.Helper() |
| 271 | for _, leg := range legs { |
| 272 | was, wasAreas := oneOhShot(t, rig, ctx, "old", doc, leg) |
| 273 | is, isAreas := oneOhShot(t, rig, ctx, "new", doc, leg) |
| 274 | ok, why, changed := samePixelsExcept(t, was, is, wasAreas, isAreas) |
| 275 | if !ok { |
| 276 | t.Errorf("1.0.0's %s at %s renders differently under the tree's CSS: %s", strings.TrimSuffix(doc, ".html"), leg, why) |
| 277 | } |
| 278 | covered = covered || changed |
| 279 | } |
| 280 | return covered |
| 281 | } |
| 282 | |
| 283 | // oneOhLayouts is 1.0.0's seven layout documents, by name, read off the |
| 284 | // frozen tree rather than the manifest so a layout the manifest lost |
| 285 | // is still photographed. Fewer than seven is a broken copy. |
| 286 | func oneOhLayouts(t *testing.T) []string { |
| 287 | t.Helper() |
| 288 | paths, err := filepath.Glob(filepath.Join("testdata", "seed", "examples", "layout-*", "default.html")) |
| 289 | if err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | var out []string |
| 293 | for _, p := range paths { |
| 294 | out = append(out, filepath.Base(filepath.Dir(p))) |
| 295 | } |
| 296 | if len(out) < 7 { |
| 297 | t.Fatalf("testdata/seed/examples holds %d layout documents, want 1.0.0's seven: %v", len(out), out) |
| 298 | } |
| 299 | return out |
| 300 | } |
| 301 | |
| 302 | // oneOhLayoutLegs is how each layout is looked at in each scheme: the |
| 303 | // three widths the shells change at, left to right, and five widths |
| 304 | // right to left, where a logical property written as a physical one |
| 305 | // would show. |
| 306 | func oneOhLayoutLegs(scheme, media string) []oneOhLeg { |
| 307 | var legs []oneOhLeg |
| 308 | for _, w := range []int64{900, 600, 320} { |
| 309 | legs = append(legs, oneOhLeg{width: w, scheme: scheme, media: media}) |
| 310 | } |
| 311 | for _, w := range []int64{1280, 900, 600, 390, 320} { |
| 312 | legs = append(legs, oneOhLeg{width: w, rtl: true, scheme: scheme, media: media}) |
| 313 | } |
| 314 | return legs |
| 315 | } |
| 316 | |
| 317 | // oneOhLayoutsRender is the k-th of the six parts of |
| 318 | // TestOneOhLayoutsRenderAsTheyDid: the seven layouts in one theme and |
| 319 | // one scheme, and in Day light also under forced colours and under |
| 320 | // print at 1280 and 390, the two media whose rules no screen |
| 321 | // photograph reaches. Six parts, one a theme and scheme, so each runs |
| 322 | // in well under its deadline on the fleet. |
| 323 | func oneOhLayoutsRender(t *testing.T, k int) { |
| 324 | themes := fiesta.ThemeNames() |
| 325 | if len(themes) != 3 { |
| 326 | t.Fatalf("fiesta ships %d themes, and this test is dealt into six parts, one a theme and scheme", len(themes)) |
| 327 | } |
| 328 | theme, scheme := themes[k/2], []string{"light", "dark"}[k%2] |
| 329 | layouts := oneOhLayouts(t) |
| 330 | rig, ctx := oneOhRig(t, theme, nil) |
| 331 | covered := false |
| 332 | for _, layout := range layouts { |
| 333 | if sameOneOh(t, rig, ctx, layout+"/default.html", oneOhLayoutLegs(scheme, "screen")) { |
| 334 | covered = true |
| 335 | } |
| 336 | } |
| 337 | // Day draws the ring and its shadow in both schemes, so a Day part |
| 338 | // where the exception covered no change is one where the fix is |
| 339 | // gone or the exception has drifted off it. |
| 340 | if theme == "day" && !covered { |
| 341 | t.Errorf("day %s: consoleRailRing covered no change in any layout; %s, and an exception for nothing is a hole waiting to be used", scheme, consoleRailRing.Why) |
| 342 | } |
| 343 | if theme != "day" || scheme != "light" { |
| 344 | return |
| 345 | } |
| 346 | for _, m := range []struct { |
| 347 | name, query string |
| 348 | media *emulation.SetEmulatedMediaParams |
| 349 | }{ |
| 350 | {"forced colours", "(forced-colors: active)", forcedMedia}, |
| 351 | {"print", "print", emulation.SetEmulatedMedia().WithMedia("print")}, |
| 352 | } { |
| 353 | rig, ctx := oneOhRig(t, theme, m.media) |
| 354 | var on bool |
| 355 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/old/examples/"+layouts[0]+"/default.html"), chromedp.Evaluate(`matchMedia(`+jsStr(m.query)+`).matches`, &on)) |
| 356 | if !on { |
| 357 | t.Fatalf("%s is not emulated; its photographs would be the screen's", m.name) |
| 358 | } |
| 359 | for _, layout := range layouts { |
| 360 | sameOneOh(t, rig, ctx, layout+"/default.html", []oneOhLeg{{width: 1280, scheme: "light", media: m.name}, {width: 390, scheme: "light", media: m.name}}) |
| 361 | } |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | func TestOneOhLayoutsRenderAsTheyDid1of6(t *testing.T) { oneOhLayoutsRender(t, 0) } |
| 366 | func TestOneOhLayoutsRenderAsTheyDid2of6(t *testing.T) { oneOhLayoutsRender(t, 1) } |
| 367 | func TestOneOhLayoutsRenderAsTheyDid3of6(t *testing.T) { oneOhLayoutsRender(t, 2) } |
| 368 | func TestOneOhLayoutsRenderAsTheyDid4of6(t *testing.T) { oneOhLayoutsRender(t, 3) } |
| 369 | func TestOneOhLayoutsRenderAsTheyDid5of6(t *testing.T) { oneOhLayoutsRender(t, 4) } |
| 370 | func TestOneOhLayoutsRenderAsTheyDid6of6(t *testing.T) { oneOhLayoutsRender(t, 5) } |
| 371 | |
| 372 | // oneOhStates is every non-layout state of 1.0.0's examples, as |
| 373 | // <name>/<state>.html, in its manifest's order. Fewer than 150 is a |
| 374 | // broken copy: 1.0.0 has 156. |
| 375 | func oneOhStates(t *testing.T) []string { |
| 376 | t.Helper() |
| 377 | m, err := example.Load(os.DirFS(filepath.Join("testdata", "seed"))) |
| 378 | if err != nil { |
| 379 | t.Fatal(err) |
| 380 | } |
| 381 | var out []string |
| 382 | for _, e := range m.Examples { |
| 383 | if e.Document || e.Kind == "layout" { |
| 384 | continue |
| 385 | } |
| 386 | for _, s := range e.States { |
| 387 | out = append(out, e.Name+"/"+s.ID+".html") |
| 388 | } |
| 389 | } |
| 390 | if len(out) < 150 { |
| 391 | t.Fatalf("1.0.0's manifest lists %d non-layout states, want at least 150; the copy under testdata/seed/examples is broken", len(out)) |
| 392 | } |
| 393 | return out |
| 394 | } |
| 395 | |
| 396 | // oneOhExamplesRender is the k-th of n parts of |
| 397 | // TestEveryOneOhExampleRendersAsItDid: its share of 1.0.0's states, in |
| 398 | // manifest order, in Day light at 900 and 390, in both directions. |
| 399 | // Dealt into parts so each runs in its own slice of the suite. |
| 400 | func oneOhExamplesRender(t *testing.T, k, n int) { |
| 401 | states := oneOhStates(t) |
| 402 | part := states[k*len(states)/n : (k+1)*len(states)/n] |
| 403 | if len(part) == 0 { |
| 404 | t.Fatalf("part %d of %d holds no state", k+1, n) |
| 405 | } |
| 406 | rig, ctx := oneOhRig(t, "day", nil) |
| 407 | var legs []oneOhLeg |
| 408 | for _, rtl := range []bool{false, true} { |
| 409 | for _, w := range []int64{900, 390} { |
| 410 | legs = append(legs, oneOhLeg{width: w, rtl: rtl, scheme: "light", media: "screen"}) |
| 411 | } |
| 412 | } |
| 413 | for _, doc := range part { |
| 414 | sameOneOh(t, rig, ctx, doc, legs) |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | func TestEveryOneOhExampleRendersAsItDid1of8(t *testing.T) { oneOhExamplesRender(t, 0, 8) } |
| 419 | func TestEveryOneOhExampleRendersAsItDid2of8(t *testing.T) { oneOhExamplesRender(t, 1, 8) } |
| 420 | func TestEveryOneOhExampleRendersAsItDid3of8(t *testing.T) { oneOhExamplesRender(t, 2, 8) } |
| 421 | func TestEveryOneOhExampleRendersAsItDid4of8(t *testing.T) { oneOhExamplesRender(t, 3, 8) } |
| 422 | func TestEveryOneOhExampleRendersAsItDid5of8(t *testing.T) { oneOhExamplesRender(t, 4, 8) } |
| 423 | func TestEveryOneOhExampleRendersAsItDid6of8(t *testing.T) { oneOhExamplesRender(t, 5, 8) } |
| 424 | func TestEveryOneOhExampleRendersAsItDid7of8(t *testing.T) { oneOhExamplesRender(t, 6, 8) } |
| 425 | func TestEveryOneOhExampleRendersAsItDid8of8(t *testing.T) { oneOhExamplesRender(t, 7, 8) } |
| 426 | |