Files
member-console/test/e2e/browsertest/browsertest.go
T
cgalo5758 408fa6f5a6 Add page anatomy parts and UI quality gate
- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge,
  emptyState) parsed into every template set
- Add anatomy lint rules with a shrinking allowlist and screen-coverage
  check
- Add make screens capture harness with contact sheets and baseline diff
- Compose member and FedWiki regions server-side so pages arrive
  complete
- Rebuild Domains and Integrations on the parts as pilots
2026-08-30 04:05:31 -05:00

152 lines
4.4 KiB
Go

// Package browsertest holds the browser helpers the e2e suites share: the
// test stack's environment, a reachability-checked base URL, a rod browser,
// and a Keycloak login. The operator walkthroughs and the screens harness
// (test/e2e/screens, `make screens`) both import it, so a stack or login
// change is made once. Functions skip rather than fail when the stack is
// not up, per the test/AGENTS.md contract.
package browsertest
import (
"bufio"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
)
// EnvFromTestDir reads key=value lines from test/.env, found by walking up
// from the working directory to the directory holding both
// bootstrap-stack.sh and .env. Skips when the file is missing.
func EnvFromTestDir(t *testing.T) map[string]string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for i := 0; i < 6; i++ {
envPath := filepath.Join(dir, ".env")
bootstrapPath := filepath.Join(dir, "bootstrap-stack.sh")
if statOK(envPath) && statOK(bootstrapPath) {
return parseEnvFile(t, envPath)
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Skip("test/.env not found; run test/bootstrap-stack.sh first (see test/AGENTS.md)")
return nil
}
func statOK(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func parseEnvFile(t *testing.T, path string) map[string]string {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer f.Close()
out := make(map[string]string)
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
out[strings.TrimSpace(line[:eq])] = strings.TrimSpace(line[eq+1:])
}
return out
}
// BaseURL returns the running member-console base URL from test/.env,
// without a trailing slash. Skips when the URL is missing or the host does
// not answer a TCP connect (tcp4, to avoid IPv6 delays on .localhost).
func BaseURL(t *testing.T) string {
t.Helper()
env := EnvFromTestDir(t)
b := env["MC_BASE_URL"]
if b == "" {
t.Skip("MC_BASE_URL not set in test/.env")
}
u, err := url.Parse(b)
if err != nil {
t.Fatalf("parse MC_BASE_URL %q: %v", b, err)
}
host := u.Host
if !strings.Contains(host, ":") {
host = host + ":80"
}
conn, err := net.DialTimeout("tcp4", host, 3*time.Second)
if err != nil {
t.Skipf("member-console not reachable at %s: %v (start it with `cd test && go run .. start --config mc-config.yaml`)", b, err)
}
_ = conn.Close()
return strings.TrimRight(b, "/")
}
// NewBrowser launches a headless Chromium through rod and closes it when
// the test ends. WALKTHROUGH_HEADFUL=1 shows the window.
func NewBrowser(t *testing.T) *rod.Browser {
t.Helper()
l := launcher.New()
if os.Getenv("WALKTHROUGH_HEADFUL") == "1" {
l = l.Headless(false)
}
wsURL, err := l.Launch()
if err != nil {
t.Skipf("could not launch chromium (rod): %v", err)
}
browser := rod.New().ControlURL(wsURL).MustConnect()
t.Cleanup(func() {
_ = browser.Close()
l.Cleanup()
})
return browser
}
// LoginAs navigates to baseURL+startPath and, when Keycloak's login form
// appears, signs in as username (every seeded test user's password is
// "password"), then lands on startPath again. With a valid session cookie
// it just navigates.
func LoginAs(t *testing.T, page *rod.Page, baseURL, startPath, username string) {
t.Helper()
page.Timeout(20 * time.Second).MustNavigate(baseURL + startPath)
hasLoginForm := rod.Try(func() {
page.Timeout(10 * time.Second).MustElement("input#username")
}) == nil
if hasLoginForm {
page.MustElement("input#username").MustSelectAllText().MustInput(username)
page.MustElement("input#password").MustSelectAllText().MustInput("password")
wait := page.MustWaitNavigation()
page.MustElement("button[type=submit], input[type=submit]").MustClick()
wait()
// Post-callback we land at /, not startPath; navigate again.
page.Timeout(20 * time.Second).MustNavigate(baseURL + startPath)
}
}
// LoginAsOperator signs in as alice (the seeded operator) and waits for the
// operator shell's main region.
func LoginAsOperator(t *testing.T, page *rod.Page, baseURL, startPath string) {
t.Helper()
LoginAs(t, page, baseURL, startPath, "alice")
page.Timeout(15 * time.Second).MustElement("#operator-main")
}