| 1 | //go:build browser |
| 2 | |
| 3 | package harness |
| 4 | |
| 5 | import ( |
| 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. |
| 21 | type 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. |
| 30 | type 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. |
| 49 | type requestInfo struct{ method, url string } |
| 50 | |
| 51 | type config struct { |
| 52 | withScrollbars bool |
| 53 | coarsePointer bool |
| 54 | hover bool |
| 55 | oneRaster bool |
| 56 | } |
| 57 | |
| 58 | // Option adjusts what New builds. |
| 59 | type 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. |
| 72 | func 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. |
| 84 | func 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. |
| 98 | func 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. |
| 108 | func 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. |
| 120 | func 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. |
| 210 | func (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. |
| 216 | func (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. |
| 226 | func (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 | |