rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package check
4
5import (
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.
43const 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.
55func 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.
124func 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.
147type oneOhBrowser struct {
148 product, revision string
149 args []string
150 features map[string]string
151}
152
153func 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.
179func 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.
195type oneOhLeg struct {
196 width int64
197 rtl bool
198 scheme string
199 media string // "screen", "forced colours" or "print"
200}
201
202func (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.
214const 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).
227const 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.
233func 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.
269func 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.
286func 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.
306func 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.
323func 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
365func TestOneOhLayoutsRenderAsTheyDid1of6(t *testing.T) { oneOhLayoutsRender(t, 0) }
366func TestOneOhLayoutsRenderAsTheyDid2of6(t *testing.T) { oneOhLayoutsRender(t, 1) }
367func TestOneOhLayoutsRenderAsTheyDid3of6(t *testing.T) { oneOhLayoutsRender(t, 2) }
368func TestOneOhLayoutsRenderAsTheyDid4of6(t *testing.T) { oneOhLayoutsRender(t, 3) }
369func TestOneOhLayoutsRenderAsTheyDid5of6(t *testing.T) { oneOhLayoutsRender(t, 4) }
370func 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.
375func 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.
400func 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
418func TestEveryOneOhExampleRendersAsItDid1of8(t *testing.T) { oneOhExamplesRender(t, 0, 8) }
419func TestEveryOneOhExampleRendersAsItDid2of8(t *testing.T) { oneOhExamplesRender(t, 1, 8) }
420func TestEveryOneOhExampleRendersAsItDid3of8(t *testing.T) { oneOhExamplesRender(t, 2, 8) }
421func TestEveryOneOhExampleRendersAsItDid4of8(t *testing.T) { oneOhExamplesRender(t, 3, 8) }
422func TestEveryOneOhExampleRendersAsItDid5of8(t *testing.T) { oneOhExamplesRender(t, 4, 8) }
423func TestEveryOneOhExampleRendersAsItDid6of8(t *testing.T) { oneOhExamplesRender(t, 5, 8) }
424func TestEveryOneOhExampleRendersAsItDid7of8(t *testing.T) { oneOhExamplesRender(t, 6, 8) }
425func TestEveryOneOhExampleRendersAsItDid8of8(t *testing.T) { oneOhExamplesRender(t, 7, 8) }
426