// 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") }