Set up e2e test harness

This commit is contained in:
Jonathon Leight committed 2026-07-02 12:47:03 -04:00
1 parent 477bcf6ab5
commit 0b54870fa1
4 files changed
+382

No files matched your search

+7
View File
@@ -6,6 +6,8 @@ require (
github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/brianvoe/gofakeit/v7 v7.15.0
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/coder/websocket v1.8.15
github.com/go-chi/chi/v5 v5.3.0
github.com/go-webauthn/webauthn v0.17.4
@@ -28,6 +30,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -40,11 +43,15 @@ require (
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
+19
View File
@@ -20,6 +20,12 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -54,6 +60,8 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -67,6 +75,12 @@ github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHl
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -102,6 +116,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
@@ -142,6 +158,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/peterstace/simplefeatures v0.59.0 h1:pmn+uh75K3CCGsJCLHnpBqgQmDECLYX3u5hfymVbqmQ=
github.com/peterstace/simplefeatures v0.59.0/go.mod h1:0QH884YeU4jOeM6Bh7EDdDFyYU1L0I0QONxwwFiknqc=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -240,6 +258,7 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+312
View File
@@ -0,0 +1,312 @@
//go:build browser
// Package e2e holds browser (end-to-end) tests: they drive a real headless
// Chrome against a live server, validating things Go tests can't — rendered
// DOM, client JS, and (critically) that the strict CSP doesn't break a page
// (CSP violations only surface in the browser console).
//
// The suite is deliberately its own black-box package, depending only on the
// public surface of the app (core.NewServer, the store/auth/identity
// constructors, the /session/callback handoff), so browser tests accumulate
// here without cluttering — or coupling to the internals of — the shipping
// packages. Add a new feature's test as its own <feature>_test.go and reuse the
// e2eServer harness below.
//
// Everything is gated behind the `browser` build tag so the default
// `go test ./...` (and current CI) never needs a browser. Run it with
// `mise run e2e`, which starts a chromedp/headless-shell container. If that
// container isn't reachable the tests t.Skip rather than fail.
//
// Networking note: the browser runs inside the container while the test server
// runs on the Docker host, so the httptest listener binds 0.0.0.0 and the
// browser reaches it via host.docker.internal (both addresses are
// env-overridable for CI — see devtoolsBase/browserHost).
package e2e
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
meshcore "github.com/meshcore-go/meshcore-go"
"github.com/jleight/meshtender/internal/auth"
"github.com/jleight/meshtender/internal/config"
"github.com/jleight/meshtender/internal/core"
"github.com/jleight/meshtender/internal/identity"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/testdb"
)
// TestMain manages the shared testdb container/template for the whole package.
func TestMain(m *testing.M) {
os.Exit(testdb.RunMain(m))
}
// migrate applies the schema to the template database (cloned per test by
// testdb.Fresh), releasing its connection before the clone.
func migrate(dsn string) error {
ctx := context.Background()
st, err := store.New(ctx, dsn)
if err != nil {
return err
}
defer st.Close()
return st.Migrate(ctx)
}
// The harness talks to a browser it doesn't launch, over addresses that differ
// between local Docker and CI, so both are env-overridable:
// - devtoolsBase: where headless-shell's DevTools endpoint lives. Locally the
// published port on the Docker host; in CI the service hostname.
// - browserHost: how the browser reaches back to this process's test server.
// Locally host.docker.internal; in CI this step's service hostname.
func devtoolsBase() string { return envOr("E2E_DEVTOOLS_URL", "http://127.0.0.1:9222") }
func browserHost() string { return envOr("E2E_BROWSER_HOST", "host.docker.internal") }
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// e2eServer is a running app server wired to a fresh test DB, addressable both
// from the host (hostURL, for the seed-session Go client) and from the browser
// container (browserURL).
type e2eServer struct {
store *store.Store
ctx context.Context
ts *httptest.Server
hostURL string // http://127.0.0.1:PORT — reachable from this process
browserURL string // http://host.docker.internal:PORT — reachable from the container
}
// newE2EServer stands up the app on a 0.0.0.0 listener so the browser container
// can connect back to it, and returns both address forms.
func newE2EServer(t *testing.T) *e2eServer {
t.Helper()
ctx := context.Background()
st, err := store.New(ctx, testdb.Fresh(t, migrate))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(st.Close)
var masterKey [32]byte
_, _ = rand.Read(masterKey[:])
idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey)
authSvc, err := auth.New(st, st.Pool(), auth.Config{
RPID: "localhost", RPDisplayName: "test", RPOrigins: []string{"http://localhost"},
})
if err != nil {
t.Fatalf("auth: %v", err)
}
// Empty config → the Dispatcher serves every host from the app surface, so
// no Host-header juggling is needed (the browser hits host.docker.internal).
srv, err := core.NewServer(st, authSvc, idSvc, &config.Config{})
if err != nil {
t.Fatalf("server: %v", err)
}
ln, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
ts := httptest.NewUnstartedServer(srv.Handler())
ts.Listener.Close() // drop the default 127.0.0.1 listener
ts.Listener = ln
ts.Start()
t.Cleanup(ts.Close)
port := ln.Addr().(*net.TCPAddr).Port
// ts.URL reflects the 0.0.0.0 listener; rewrite it to loopback so the
// host-side Go client dials a concrete address.
ts.URL = fmt.Sprintf("http://127.0.0.1:%d", port)
return &e2eServer{
store: st,
ctx: ctx,
ts: ts,
hostURL: ts.URL,
browserURL: fmt.Sprintf("http://%s:%d", browserHost(), port),
}
}
// login creates a user, establishes an authenticated session via the
// /session/callback handoff (the same seam the HTTP handler tests use, but
// reached only through public APIs), and returns the user plus the session
// cookie to inject into the browser.
func (e *e2eServer) login(t *testing.T, username string) (*store.User, *http.Cookie) {
t.Helper()
u, err := e.store.CreateUser(e.ctx, username, "")
if err != nil {
t.Fatalf("create user: %v", err)
}
loginID, err := e.store.CreateLogin(e.ctx, u.ID)
if err != nil {
t.Fatalf("create login: %v", err)
}
code, err := e.store.CreateAuthCode(e.ctx, u.ID, loginID, "/")
if err != nil {
t.Fatalf("create auth code: %v", err)
}
jar, _ := cookiejar.New(nil)
req, _ := http.NewRequest(http.MethodGet, e.hostURL+"/session/callback?code="+code+"&state=s1", nil)
req.AddCookie(&http.Cookie{Name: "mt_state", Value: "s1"})
client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("session callback: %v", err)
}
resp.Body.Close()
base, _ := url.Parse(e.hostURL)
for _, c := range jar.Cookies(base) {
if c.Name == "meshtender_session" {
return u, c
}
}
t.Fatal("no session cookie after handoff")
return nil, nil
}
// newRepeater creates a repeater owned by ownerID with a valid MeshCore key.
func (e *e2eServer) newRepeater(t *testing.T, ownerID int64, name string) *store.Repeater {
t.Helper()
id, _ := meshcore.GenerateLocalIdentity(rand.Reader)
rep, err := e.store.CreateRepeater(e.ctx, &store.Repeater{
OwnerID: ownerID, Name: name, PublicKeyHex: id.String(),
RadioFreqHz: 869525000, RadioBwHz: 250000, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
return rep
}
// consoleWatch records browser console errors and CSP violations seen during a
// test, so a test can assert the page ran cleanly under the strict CSP.
type consoleWatch struct {
mu sync.Mutex
msgs []string
}
func (w *consoleWatch) add(s string) {
w.mu.Lock()
w.msgs = append(w.msgs, s)
w.mu.Unlock()
}
// violations returns any recorded message mentioning a CSP violation.
func (w *consoleWatch) violations() []string {
w.mu.Lock()
defer w.mu.Unlock()
var out []string
for _, m := range w.msgs {
if strings.Contains(strings.ToLower(m), "content security policy") {
out = append(out, m)
}
}
return out
}
// assertClean fails if any CSP violation was reported.
func (w *consoleWatch) assertClean(t *testing.T) {
t.Helper()
if v := w.violations(); len(v) > 0 {
t.Fatalf("CSP violation(s) in browser console:\n%s", strings.Join(v, "\n"))
}
}
// startBrowser connects to the headless-shell container and returns a chromedp
// context, a cancel func, and a console watcher. It skips the test if the
// container isn't reachable.
func startBrowser(t *testing.T) (context.Context, context.CancelFunc, *consoleWatch) {
t.Helper()
wsURL := devtoolsWebSocket(t)
allocCtx, cancelAlloc := chromedp.NewRemoteAllocator(context.Background(), wsURL)
ctx, cancelCtx := chromedp.NewContext(allocCtx)
watch := &consoleWatch{}
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) {
case *cdplog.EventEntryAdded:
// CSP violations arrive here with source="security", level="error".
if e.Entry != nil {
watch.add(e.Entry.Text)
}
case *runtime.EventConsoleAPICalled:
if e.Type == "error" {
var parts []string
for _, a := range e.Args {
parts = append(parts, string(a.Value))
}
watch.add(strings.Join(parts, " "))
}
}
})
cancel := func() { cancelCtx(); cancelAlloc() }
return ctx, cancel, watch
}
// devtoolsWebSocket asks the headless-shell container for its browser-level
// DevTools websocket URL, skipping the test if the container is down.
func devtoolsWebSocket(t *testing.T) string {
t.Helper()
base := devtoolsBase()
c := &http.Client{Timeout: 3 * time.Second}
resp, err := c.Get(base + "/json/version")
if err != nil {
t.Skipf("headless-shell not reachable at %s (run `mise run e2e`): %v", base, err)
}
defer resp.Body.Close()
var v struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil || v.WebSocketDebuggerURL == "" {
t.Skipf("headless-shell /json/version malformed: %v", err)
}
// The reported ws URL carries the browser's own view of its host (e.g.
// 127.0.0.1:9222); rewrite it to the host we actually reached it on so the
// same code works locally (published port) and in CI (service hostname).
baseURL, err := url.Parse(base)
if err != nil {
t.Fatalf("bad E2E_DEVTOOLS_URL %q: %v", base, err)
}
wsu, err := url.Parse(v.WebSocketDebuggerURL)
if err != nil {
t.Fatalf("bad webSocketDebuggerUrl %q: %v", v.WebSocketDebuggerURL, err)
}
wsu.Host = baseURL.Host
return wsu.String()
}
// setSessionCookie injects the app session cookie for the browser-facing host,
// so the browser is authenticated before it navigates.
func setSessionCookie(c *http.Cookie) chromedp.Action {
return chromedp.ActionFunc(func(ctx context.Context) error {
return network.SetCookie(c.Name, c.Value).
WithDomain(browserHost()).
WithPath("/").
WithHTTPOnly(true).
Do(ctx)
})
}
+44
View File
@@ -0,0 +1,44 @@
//go:build browser
package e2e
import (
"strings"
"testing"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
// TestE2ESmokeMaintenancePage is the harness "first light": it proves the whole
// browser-test plumbing works end to end — a headless Chrome in a container
// authenticates via an injected session cookie, reaches the app on the Docker
// host, renders a protected page, and runs it under the strict CSP with no
// violations. It asserts nothing about any specific bug.
func TestE2ESmokeMaintenancePage(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "smokeuser")
rep := srv.newRepeater(t, user.ID, "Smoke Repeater")
bctx, cancel, watch := startBrowser(t)
defer cancel()
url := srv.browserURL + "/repeaters/" + rep.PublicID + "/maintenance"
var formHTML string
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(url),
chromedp.WaitVisible(`form[action$="/maintenance"]`, chromedp.ByQuery),
chromedp.OuterHTML(`form[action$="/maintenance"]`, &formHTML, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", url, err)
}
if !strings.Contains(formHTML, "Add entry") {
t.Fatalf("maintenance page did not render the Add-entry form; got:\n%s", formHTML)
}
watch.assertClean(t)
}