//go:build browser package harness import ( "context" "fmt" "net" "net/http" "net/http/httptest" "sync" "testing" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" ) // allowance is one expected probe: a request the page makes on purpose // that would otherwise read as a failure. The browser's own // /favicon.ico probe is the standing example. type allowance struct { method string path string status int } // Rig is one fixture under one browser: a real server on a localhost // origin, a real Chromium, and the watchers that make a silent failure // impossible. type Rig struct { // Origin is the fixture's origin, "http://localhost:PORT". Always // navigate this, never a 127.0.0.1 URL, so a drive and the page // agree on what same-origin means. Origin string t *testing.T ctx context.Context mu sync.Mutex problems []string allows []allowance allowedText []string requests map[network.RequestID]requestInfo } // requestInfo is what a later network event cannot tell us about // itself: a response and a load failure carry only a RequestID, so the // method and URL come from correlating network.EventRequestWillBeSent. type requestInfo struct{ method, url string } type config struct { withScrollbars bool coarsePointer bool hover bool oneRaster bool } // Option adjusts what New builds. type Option func(*config) // WithScrollbars leaves the browser's scrollbars where the platform // draws them. // // chromedp's headless defaults hide them — --hide-scrollbars, copied // from Puppeteer — and that is invisible and harmless right up until a // drive's SUBJECT is the scrollbar. A browser that draws none takes no // width out of the layout, so a drive measuring whether a scrollbar // shifts the page would measure zero either way and agree with whatever // the stylesheet claimed. The scrollbar-gutter drive is exactly that // drive, and it fails rather than passes if this option stops working: // its control page asks for the shift and requires to see it. func WithScrollbars() Option { return func(c *config) { c.withScrollbars = true } } // WithCoarsePointer launches Chromium with a touch screen as its // primary pointer, so (pointer: coarse) matches and (hover: hover) does // not. It is a launch option because nothing later can do it: // Emulation.setTouchEmulationEnabled, a mobile setDeviceMetricsOverride // and setEmulatedMedia all leave matchMedia("(pointer: coarse)") false // in this Chromium, while these Blink settings, read once at launch, // make it true. A phone drive that silently ran on a fine pointer would // pass at desktop sizes, so check the media query before measuring. func WithCoarsePointer() Option { return func(c *config) { c.coarsePointer = true } } // WithDeterministicRaster launches Chromium with one raster thread, no // GPU and no GPU rasterization, no partial raster and grayscale text // antialiasing, for drives that compare two photographs at zero // tolerance. Under load the default rig painted one antialiased pixel // one level apart on two loads of one page under one stylesheet, in 4 // of 400 pairs; this configuration painted 0 of 400 pairs apart under // the same load. That is what it is shown to do, nothing more: two // photographs taken this way agree with each other, and a difference // only GPU raster, partial repaint or LCD text would show is outside // what they compare. func WithDeterministicRaster() Option { return func(c *config) { c.oneRaster = true } } // WithHover launches Chromium with a mouse that can hover, so // (hover: hover) matches. Headless Chromium's default is (hover: none) // even on a desktop, measured here: a drive that read a rule inside // @media (hover: hover) on the default rig would read nothing and pass // for the wrong reason. 4 is Blink's PointerType fine and 2 its // HoverType hover; the available* pair has to agree with the primary. func WithHover() Option { return func(c *config) { c.hover = true } } // New boots the rig. Order is the point: a fixture may need its origin // before building its handler, but the port doesn't exist until a // listener does — so New binds the listener first, computes origin, // calls build(origin), serves on that listener, and only then launches // Chromium. // // Cleanups are registered so t.Cleanup's LIFO tears the browser down // before the server it is talking to. func New(t *testing.T, build func(origin string) http.Handler, opts ...Option) *Rig { t.Helper() var cfg config for _, o := range opts { o(&cfg) } chrome := ChromePath(t) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("harness: listen: %v", err) } origin := fmt.Sprintf("http://localhost:%d", ln.Addr().(*net.TCPAddr).Port) srv := httptest.NewUnstartedServer(build(origin)) srv.Listener.Close() srv.Listener = ln srv.Start() t.Cleanup(srv.Close) allocOpts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(chrome), chromedp.Flag("headless", true), chromedp.NoSandbox, ) if cfg.withScrollbars { // After the defaults, so it wins: chromedp.Headless set this // true and the flag map keeps the last value written. allocOpts = append(allocOpts, chromedp.Flag("hide-scrollbars", false)) } if cfg.hover { allocOpts = append(allocOpts, chromedp.Flag("blink-settings", "primaryPointerType=4,availablePointerTypes=4,primaryHoverType=2,availableHoverTypes=2")) } if cfg.coarsePointer { // 2 is Blink's PointerType coarse and 1 its HoverType none; the // available* pair has to agree or any-pointer disagrees with // pointer, which no real phone does. allocOpts = append(allocOpts, chromedp.Flag("blink-settings", "primaryPointerType=2,availablePointerTypes=2,primaryHoverType=1,availableHoverTypes=1")) } if cfg.oneRaster { allocOpts = append(allocOpts, chromedp.Flag("num-raster-threads", "1"), chromedp.Flag("disable-gpu", true), chromedp.Flag("disable-gpu-rasterization", true), chromedp.Flag("disable-partial-raster", true), chromedp.Flag("disable-lcd-text", true), ) } allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), allocOpts...) ctx, cancelCtx := chromedp.NewContext(allocCtx) r := &Rig{ Origin: origin, t: t, ctx: ctx, requests: map[network.RequestID]requestInfo{}, } // The browser probes /favicon.ico on its own; pre-allowed so every // fixture doesn't rediscover it as a mysterious 404. r.Allow(http.MethodGet, "/favicon.ico", http.StatusNotFound) r.watch() // Launch now rather than at the first action, so a browser that // cannot start fails here, named as the boot, instead of inside // whichever drive happened to run first. if err := chromedp.Run(r.ctx); err != nil { // The contexts only: chromedp.Cancel on a context whose browser // never started waits forever for a process that does not // exist (measured with FIESTA_CHROME=/nonexistent, the hang // lasted until go test's own alarm), so the graceful shutdown // below is registered only once there is a browser to shut. cancelCtx() cancelAlloc() t.Fatalf("harness: browser boot: %v", err) } t.Cleanup(func() { // Graceful browser shutdown first, then the contexts. _ = chromedp.Cancel(ctx) cancelCtx() cancelAlloc() }) return r } // Context is the rig's chromedp context — derive a deadline from it // when a drive wants a tighter budget than the test binary's own // timeout. func (r *Rig) Context() context.Context { return r.ctx } // Run drives the browser: plain chromedp actions, no DSL — a drive is // a top-to-bottom script and that is the right amount of framework. On // failure it fails the test with whatever was on screen: a failure // report always includes what a person would have seen. func (r *Rig) Run(actions ...chromedp.Action) { r.t.Helper() if err := chromedp.Run(r.ctx, actions...); err != nil { r.t.Fatalf("harness: drive failed: %v\non screen:\n%s", err, r.onScreen("body")) } } // onScreen reads sel's innerText (falling back to the whole body) // best-effort — it feeds failure reports, so its own errors are // swallowed rather than masking the real one. func (r *Rig) onScreen(sel string) string { var text string expr := fmt.Sprintf(`((document.querySelector(%q) ?? document.body)?.innerText ?? "")`, sel) _ = chromedp.Run(r.ctx, chromedp.Evaluate(expr, &text)) return text }