- 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
152 lines
4.4 KiB
Go
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")
|
|
}
|