//go:build browser package check import ( "context" "encoding/json" "fmt" "io/fs" "net/http" "os" "path" "path/filepath" "slices" "strings" "testing" "time" "github.com/chromedp/cdproto/browser" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/systeminfo" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/harness" ) // The 1.0 photographs: 1.0.0's own examples, copied from the tag into // testdata/seed/examples/, photographed under 1.0.0's CSS and under the // tree's, pixel for pixel. A site that links /1/ gets each 1.x release // the day it ships with no edit, so 1.0 markup must draw exactly as it // did. The current examples would not do: they are 1.x markup, which // 1.0.0's CSS never styled. The declaration gates (baseline_test.go) // say what may change; these say what a reader sees did not, at the // widths and in the media the declaration gates cannot see into. // rtlQuery asks oneOhTree for a document turned right to left: its // served as lang="ar" dir="rtl", so the // page's scripts see the direction from the first byte, as they would // on an Arabic page. const rtlQuery = "dir=rtl" // oneOhTree serves one side of the comparison under //, laid out // as the repository is, so the frozen documents' own relative links // (../../css/tokens.css, ../../js/, ../assets/) work // unrewritten. css/tokens.css, css/shell.css and css/themes/day.css // are the baseline's for old and the tree's for new; day.css carries // the theme under test, so the documents need no rewriting to change // theme. js/ is the tree's on both sides (TestOneOhScriptsAreUnchanged // holds it to 1.0.0's), and examples/ is the frozen copy on both, // pictures included, byte for byte. Anything else is a 404, which the // rig reports, so a link the tree does not serve fails as itself. func oneOhTree(t *testing.T, side, theme string) http.Handler { t.Helper() var tokens, shell, themeCSS []byte switch side { case "old": tokens, shell, themeCSS = seedFile(t, "tokens.css"), seedFile(t, "shell.css"), seedFile(t, theme+".css") case "new": var ok bool if themeCSS, ok = fiesta.ThemeCSS(theme); !ok { t.Fatalf("no theme %q", theme) } tokens, shell = fiesta.TokensCSS(), fiesta.ShellCSS() default: t.Fatalf("oneOhTree: side %q is neither old nor new", side) } css := map[string][]byte{"css/tokens.css": tokens, "css/shell.css": shell, "css/themes/day.css": themeCSS} seed := os.DirFS(filepath.Join("testdata", "seed")) prefix := "/" + side + "/" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { file, ok := strings.CutPrefix(r.URL.Path, prefix) if !ok || !fs.ValidPath(file) { http.NotFound(w, r) return } if b, ok := css[file]; ok { w.Header().Set("Content-Type", "text/css") w.Write(b) return } if name, ok := strings.CutPrefix(file, "js/"); ok { b, ok := fiesta.Script(name) if !ok { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/javascript") w.Write(b) return } if !strings.HasPrefix(file, "examples/") { http.NotFound(w, r) return } b, err := fs.ReadFile(seed, file) if err != nil { http.NotFound(w, r) return } switch path.Ext(file) { case ".html": w.Header().Set("Content-Type", "text/html; charset=utf-8") if r.URL.RawQuery == rtlQuery { const ltr = ` on the page and lists those that // did not load, so a missing picture fails as a missing picture rather // than as two identical broken ones. It gives up after ten seconds and // lists what is still waiting. const imagesLoadedJS = `new Promise(r => { const imgs = [...document.images]; const done = () => r(imgs.filter(i => !(i.complete && i.naturalWidth > 0)).map(i => i.currentSrc || i.src)); const timer = setTimeout(done, 10000); Promise.all(imgs.map(i => i.complete ? null : new Promise(d => { i.addEventListener("load", d, {once: true}); i.addEventListener("error", d, {once: true}); }))) .then(() => { clearTimeout(timer); done(); }); })` // oneOhShotBudget is how long one photograph may take. A shot takes // about a tenth of a second; the budget is there so a page or a // browser that hangs fails as that shot, named, within a minute, // rather than as the whole part running into its own deadline with no // word of where it stopped (which is how a fleet run once read). const oneOhShotBudget = 60 * time.Second // oneOhShot photographs one side's copy of a document in one leg, and // reads the areas consoleRailRing covers on it. Before the photograph // every picture has loaded and the rig has seen no failed request or // response of 400 or more, or the test fails naming them. func oneOhShot(t *testing.T, rig *harness.Rig, ctx context.Context, side, doc string, leg oneOhLeg) ([]byte, []exceptedArea) { t.Helper() url := rig.Origin + "/" + side + "/examples/" + doc if leg.rtl { url += "?" + rtlQuery } where := fmt.Sprintf("%s's %s at %s", side, doc, leg) ctx, cancel := context.WithTimeout(ctx, oneOhShotBudget) defer cancel() // mustRun fails the test from inside the shot; this runs as that // failure unwinds, and says which shot it was. defer func() { if ctx.Err() == context.DeadlineExceeded { t.Errorf("%s: no answer within %s; the page or the browser hung", where, oneOhShotBudget) } }() var areas []exceptedArea shot := pageShotChecked(t, ctx, url, leg.width, leg.scheme, func() { var broken []string mustRun(t, ctx, chromedp.Evaluate(imagesLoadedJS, &broken, awaitPromise)) if len(broken) > 0 { t.Fatalf("%s: %d pictures did not load: %s", where, len(broken), strings.Join(broken, ", ")) } rig.Screen("body", where) areas = consoleRailRing.areas(t, ctx) }) return shot, areas } // sameOneOh photographs doc on both sides in each leg and fails each // leg whose pictures differ by a single pixel outside consoleRailRing's // areas, or whose areas moved. One pair is enough: the rig rasters on // one thread in software (WithDeterministicRaster), under which 400 // pairs of one page agreed under load; without it one state's callout // corner came out one level apart in 4 of them. It reports whether the // exception covered a change in any leg. func sameOneOh(t *testing.T, rig *harness.Rig, ctx context.Context, doc string, legs []oneOhLeg) (covered bool) { t.Helper() for _, leg := range legs { was, wasAreas := oneOhShot(t, rig, ctx, "old", doc, leg) is, isAreas := oneOhShot(t, rig, ctx, "new", doc, leg) ok, why, changed := samePixelsExcept(t, was, is, wasAreas, isAreas) if !ok { t.Errorf("1.0.0's %s at %s renders differently under the tree's CSS: %s", strings.TrimSuffix(doc, ".html"), leg, why) } covered = covered || changed } return covered } // oneOhLayouts is 1.0.0's seven layout documents, by name, read off the // frozen tree rather than the manifest so a layout the manifest lost // is still photographed. Fewer than seven is a broken copy. func oneOhLayouts(t *testing.T) []string { t.Helper() paths, err := filepath.Glob(filepath.Join("testdata", "seed", "examples", "layout-*", "default.html")) if err != nil { t.Fatal(err) } var out []string for _, p := range paths { out = append(out, filepath.Base(filepath.Dir(p))) } if len(out) < 7 { t.Fatalf("testdata/seed/examples holds %d layout documents, want 1.0.0's seven: %v", len(out), out) } return out } // oneOhLayoutLegs is how each layout is looked at in each scheme: the // three widths the shells change at, left to right, and five widths // right to left, where a logical property written as a physical one // would show. func oneOhLayoutLegs(scheme, media string) []oneOhLeg { var legs []oneOhLeg for _, w := range []int64{900, 600, 320} { legs = append(legs, oneOhLeg{width: w, scheme: scheme, media: media}) } for _, w := range []int64{1280, 900, 600, 390, 320} { legs = append(legs, oneOhLeg{width: w, rtl: true, scheme: scheme, media: media}) } return legs } // oneOhLayoutsRender is the k-th of the six parts of // TestOneOhLayoutsRenderAsTheyDid: the seven layouts in one theme and // one scheme, and in Day light also under forced colours and under // print at 1280 and 390, the two media whose rules no screen // photograph reaches. Six parts, one a theme and scheme, so each runs // in well under its deadline on the fleet. func oneOhLayoutsRender(t *testing.T, k int) { themes := fiesta.ThemeNames() if len(themes) != 3 { t.Fatalf("fiesta ships %d themes, and this test is dealt into six parts, one a theme and scheme", len(themes)) } theme, scheme := themes[k/2], []string{"light", "dark"}[k%2] layouts := oneOhLayouts(t) rig, ctx := oneOhRig(t, theme, nil) covered := false for _, layout := range layouts { if sameOneOh(t, rig, ctx, layout+"/default.html", oneOhLayoutLegs(scheme, "screen")) { covered = true } } // Day draws the ring and its shadow in both schemes, so a Day part // 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 %s: consoleRailRing covered no change in any layout; %s, and an exception for nothing is a hole waiting to be used", scheme, consoleRailRing.Why) } if theme != "day" || scheme != "light" { return } for _, m := range []struct { name, query string media *emulation.SetEmulatedMediaParams }{ {"forced colours", "(forced-colors: active)", forcedMedia}, {"print", "print", emulation.SetEmulatedMedia().WithMedia("print")}, } { rig, ctx := oneOhRig(t, theme, m.media) var on bool mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/old/examples/"+layouts[0]+"/default.html"), chromedp.Evaluate(`matchMedia(`+jsStr(m.query)+`).matches`, &on)) if !on { t.Fatalf("%s is not emulated; its photographs would be the screen's", m.name) } for _, layout := range layouts { sameOneOh(t, rig, ctx, layout+"/default.html", []oneOhLeg{{width: 1280, scheme: "light", media: m.name}, {width: 390, scheme: "light", media: m.name}}) } } } func TestOneOhLayoutsRenderAsTheyDid1of6(t *testing.T) { oneOhLayoutsRender(t, 0) } func TestOneOhLayoutsRenderAsTheyDid2of6(t *testing.T) { oneOhLayoutsRender(t, 1) } func TestOneOhLayoutsRenderAsTheyDid3of6(t *testing.T) { oneOhLayoutsRender(t, 2) } func TestOneOhLayoutsRenderAsTheyDid4of6(t *testing.T) { oneOhLayoutsRender(t, 3) } func TestOneOhLayoutsRenderAsTheyDid5of6(t *testing.T) { oneOhLayoutsRender(t, 4) } func TestOneOhLayoutsRenderAsTheyDid6of6(t *testing.T) { oneOhLayoutsRender(t, 5) } // oneOhStates is every non-layout state of 1.0.0's examples, as // /.html, in its manifest's order. Fewer than 150 is a // broken copy: 1.0.0 has 156. func oneOhStates(t *testing.T) []string { t.Helper() m, err := example.Load(os.DirFS(filepath.Join("testdata", "seed"))) if err != nil { t.Fatal(err) } var out []string for _, e := range m.Examples { if e.Document || e.Kind == "layout" { continue } for _, s := range e.States { out = append(out, e.Name+"/"+s.ID+".html") } } if len(out) < 150 { 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)) } return out } // oneOhExamplesRender is the k-th of n parts of // TestEveryOneOhExampleRendersAsItDid: its share of 1.0.0's states, in // manifest order, in Day light at 900 and 390, in both directions. // Dealt into parts so each runs in its own slice of the suite. func oneOhExamplesRender(t *testing.T, k, n int) { states := oneOhStates(t) part := states[k*len(states)/n : (k+1)*len(states)/n] if len(part) == 0 { t.Fatalf("part %d of %d holds no state", k+1, n) } rig, ctx := oneOhRig(t, "day", nil) var legs []oneOhLeg for _, rtl := range []bool{false, true} { for _, w := range []int64{900, 390} { legs = append(legs, oneOhLeg{width: w, rtl: rtl, scheme: "light", media: "screen"}) } } for _, doc := range part { sameOneOh(t, rig, ctx, doc, legs) } } func TestEveryOneOhExampleRendersAsItDid1of8(t *testing.T) { oneOhExamplesRender(t, 0, 8) } func TestEveryOneOhExampleRendersAsItDid2of8(t *testing.T) { oneOhExamplesRender(t, 1, 8) } func TestEveryOneOhExampleRendersAsItDid3of8(t *testing.T) { oneOhExamplesRender(t, 2, 8) } func TestEveryOneOhExampleRendersAsItDid4of8(t *testing.T) { oneOhExamplesRender(t, 3, 8) } func TestEveryOneOhExampleRendersAsItDid5of8(t *testing.T) { oneOhExamplesRender(t, 4, 8) } func TestEveryOneOhExampleRendersAsItDid6of8(t *testing.T) { oneOhExamplesRender(t, 5, 8) } func TestEveryOneOhExampleRendersAsItDid7of8(t *testing.T) { oneOhExamplesRender(t, 6, 8) } func TestEveryOneOhExampleRendersAsItDid8of8(t *testing.T) { oneOhExamplesRender(t, 7, 8) }