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 harness
4
5import (
6 "context"
7 "fmt"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "sync"
12 "testing"
13
14 "github.com/chromedp/cdproto/network"
15 "github.com/chromedp/chromedp"
16)
17
18// allowance is one expected probe: a request the page makes on purpose
19// that would otherwise read as a failure. The browser's own
20// /favicon.ico probe is the standing example.
21type allowance struct {
22 method string
23 path string
24 status int
25}
26
27// Rig is one fixture under one browser: a real server on a localhost
28// origin, a real Chromium, and the watchers that make a silent failure
29// impossible.
30type Rig struct {
31 // Origin is the fixture's origin, "http://localhost:PORT". Always
32 // navigate this, never a 127.0.0.1 URL, so a drive and the page
33 // agree on what same-origin means.
34 Origin string
35
36 t *testing.T
37 ctx context.Context
38
39 mu sync.Mutex
40 problems []string
41 allows []allowance
42 allowedText []string
43 requests map[network.RequestID]requestInfo
44}
45
46// requestInfo is what a later network event cannot tell us about
47// itself: a response and a load failure carry only a RequestID, so the
48// method and URL come from correlating network.EventRequestWillBeSent.
49type requestInfo struct{ method, url string }
50
51type config struct {
52 withScrollbars bool
53 coarsePointer bool
54 hover bool
55 oneRaster bool
56}
57
58// Option adjusts what New builds.
59type Option func(*config)
60
61// WithScrollbars leaves the browser's scrollbars where the platform
62// draws them.
63//
64// chromedp's headless defaults hide them — --hide-scrollbars, copied
65// from Puppeteer — and that is invisible and harmless right up until a
66// drive's SUBJECT is the scrollbar. A browser that draws none takes no
67// width out of the layout, so a drive measuring whether a scrollbar
68// shifts the page would measure zero either way and agree with whatever
69// the stylesheet claimed. The scrollbar-gutter drive is exactly that
70// drive, and it fails rather than passes if this option stops working:
71// its control page asks for the shift and requires to see it.
72func WithScrollbars() Option {
73 return func(c *config) { c.withScrollbars = true }
74}
75
76// WithCoarsePointer launches Chromium with a touch screen as its
77// primary pointer, so (pointer: coarse) matches and (hover: hover) does
78// not. It is a launch option because nothing later can do it:
79// Emulation.setTouchEmulationEnabled, a mobile setDeviceMetricsOverride
80// and setEmulatedMedia all leave matchMedia("(pointer: coarse)") false
81// in this Chromium, while these Blink settings, read once at launch,
82// make it true. A phone drive that silently ran on a fine pointer would
83// pass at desktop sizes, so check the media query before measuring.
84func WithCoarsePointer() Option {
85 return func(c *config) { c.coarsePointer = true }
86}
87
88// WithDeterministicRaster launches Chromium with one raster thread, no
89// GPU and no GPU rasterization, no partial raster and grayscale text
90// antialiasing, for drives that compare two photographs at zero
91// tolerance. Under load the default rig painted one antialiased pixel
92// one level apart on two loads of one page under one stylesheet, in 4
93// of 400 pairs; this configuration painted 0 of 400 pairs apart under
94// the same load. That is what it is shown to do, nothing more: two
95// photographs taken this way agree with each other, and a difference
96// only GPU raster, partial repaint or LCD text would show is outside
97// what they compare.
98func WithDeterministicRaster() Option {
99 return func(c *config) { c.oneRaster = true }
100}
101
102// WithHover launches Chromium with a mouse that can hover, so
103// (hover: hover) matches. Headless Chromium's default is (hover: none)
104// even on a desktop, measured here: a drive that read a rule inside
105// @media (hover: hover) on the default rig would read nothing and pass
106// for the wrong reason. 4 is Blink's PointerType fine and 2 its
107// HoverType hover; the available* pair has to agree with the primary.
108func WithHover() Option {
109 return func(c *config) { c.hover = true }
110}
111
112// New boots the rig. Order is the point: a fixture may need its origin
113// before building its handler, but the port doesn't exist until a
114// listener does — so New binds the listener first, computes origin,
115// calls build(origin), serves on that listener, and only then launches
116// Chromium.
117//
118// Cleanups are registered so t.Cleanup's LIFO tears the browser down
119// before the server it is talking to.
120func New(t *testing.T, build func(origin string) http.Handler, opts ...Option) *Rig {
121 t.Helper()
122 var cfg config
123 for _, o := range opts {
124 o(&cfg)
125 }
126 chrome := ChromePath(t)
127
128 ln, err := net.Listen("tcp", "127.0.0.1:0")
129 if err != nil {
130 t.Fatalf("harness: listen: %v", err)
131 }
132 origin := fmt.Sprintf("http://localhost:%d", ln.Addr().(*net.TCPAddr).Port)
133
134 srv := httptest.NewUnstartedServer(build(origin))
135 srv.Listener.Close()
136 srv.Listener = ln
137 srv.Start()
138 t.Cleanup(srv.Close)
139
140 allocOpts := append(chromedp.DefaultExecAllocatorOptions[:],
141 chromedp.ExecPath(chrome),
142 chromedp.Flag("headless", true),
143 chromedp.NoSandbox,
144 )
145 if cfg.withScrollbars {
146 // After the defaults, so it wins: chromedp.Headless set this
147 // true and the flag map keeps the last value written.
148 allocOpts = append(allocOpts, chromedp.Flag("hide-scrollbars", false))
149 }
150 if cfg.hover {
151 allocOpts = append(allocOpts, chromedp.Flag("blink-settings",
152 "primaryPointerType=4,availablePointerTypes=4,primaryHoverType=2,availableHoverTypes=2"))
153 }
154 if cfg.coarsePointer {
155 // 2 is Blink's PointerType coarse and 1 its HoverType none; the
156 // available* pair has to agree or any-pointer disagrees with
157 // pointer, which no real phone does.
158 allocOpts = append(allocOpts, chromedp.Flag("blink-settings",
159 "primaryPointerType=2,availablePointerTypes=2,primaryHoverType=1,availableHoverTypes=1"))
160 }
161 if cfg.oneRaster {
162 allocOpts = append(allocOpts,
163 chromedp.Flag("num-raster-threads", "1"),
164 chromedp.Flag("disable-gpu", true),
165 chromedp.Flag("disable-gpu-rasterization", true),
166 chromedp.Flag("disable-partial-raster", true),
167 chromedp.Flag("disable-lcd-text", true),
168 )
169 }
170 allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), allocOpts...)
171 ctx, cancelCtx := chromedp.NewContext(allocCtx)
172
173 r := &Rig{
174 Origin: origin,
175 t: t,
176 ctx: ctx,
177 requests: map[network.RequestID]requestInfo{},
178 }
179
180 // The browser probes /favicon.ico on its own; pre-allowed so every
181 // fixture doesn't rediscover it as a mysterious 404.
182 r.Allow(http.MethodGet, "/favicon.ico", http.StatusNotFound)
183 r.watch()
184
185 // Launch now rather than at the first action, so a browser that
186 // cannot start fails here, named as the boot, instead of inside
187 // whichever drive happened to run first.
188 if err := chromedp.Run(r.ctx); err != nil {
189 // The contexts only: chromedp.Cancel on a context whose browser
190 // never started waits forever for a process that does not
191 // exist (measured with FIESTA_CHROME=/nonexistent, the hang
192 // lasted until go test's own alarm), so the graceful shutdown
193 // below is registered only once there is a browser to shut.
194 cancelCtx()
195 cancelAlloc()
196 t.Fatalf("harness: browser boot: %v", err)
197 }
198 t.Cleanup(func() {
199 // Graceful browser shutdown first, then the contexts.
200 _ = chromedp.Cancel(ctx)
201 cancelCtx()
202 cancelAlloc()
203 })
204 return r
205}
206
207// Context is the rig's chromedp context — derive a deadline from it
208// when a drive wants a tighter budget than the test binary's own
209// timeout.
210func (r *Rig) Context() context.Context { return r.ctx }
211
212// Run drives the browser: plain chromedp actions, no DSL — a drive is
213// a top-to-bottom script and that is the right amount of framework. On
214// failure it fails the test with whatever was on screen: a failure
215// report always includes what a person would have seen.
216func (r *Rig) Run(actions ...chromedp.Action) {
217 r.t.Helper()
218 if err := chromedp.Run(r.ctx, actions...); err != nil {
219 r.t.Fatalf("harness: drive failed: %v\non screen:\n%s", err, r.onScreen("body"))
220 }
221}
222
223// onScreen reads sel's innerText (falling back to the whole body)
224// best-effort — it feeds failure reports, so its own errors are
225// swallowed rather than masking the real one.
226func (r *Rig) onScreen(sel string) string {
227 var text string
228 expr := fmt.Sprintf(`((document.querySelector(%q) ?? document.body)?.innerText ?? "")`, sel)
229 _ = chromedp.Run(r.ctx, chromedp.Evaluate(expr, &text))
230 return text
231}
232