Files
member-console/test/e2e/operator-walkthroughs/helpers_test.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

195 lines
8.3 KiB
Go

// Package operatorwalkthroughs implements the M7f.2 browser-driven
// verification scripts for operator mutation surfaces.
//
// Each *_test.go file is a "walkthrough" mechanizing the conventions
// contract from docs/operator-ux-conventions.md for one mutation surface.
// The four bullets each script may exercise (per the surface):
//
// 1. Submit empty/invalid input → 422 + per-field error rendered inline
// 2. Submit valid input → success toast + DOM updates
// 3. Trigger destructive action → confirm modal w/ correct copy
// 4. Force server 5xx → error toast, no UI bleed
//
// Walkthroughs require the test stack to be running. See test/AGENTS.md
// for the bootstrap contract. Tests skip cleanly if the stack is down.
//
// Each test file declares the routes it covers via a header comment that
// cmd/lint reads for the M7f.1 coverage rule (task #4 in the M7f plan):
//
// // walkthrough: products
// // covers: POST /partials/operator/products
// // PUT /partials/operator/products/{productID}
package operatorwalkthroughs_test
import (
"net/http"
"strings"
"testing"
"time"
"github.com/go-rod/rod"
"git.coopcloud.tech/wiki-cafe/member-console/test/e2e/browsertest"
)
// envFromTestDir reads test/.env (test/e2e/browsertest).
func envFromTestDir(t *testing.T) map[string]string {
t.Helper()
return browsertest.EnvFromTestDir(t)
}
// baseURL is the running member-console base URL (test/e2e/browsertest).
func baseURL(t *testing.T) string {
t.Helper()
return browsertest.BaseURL(t)
}
// skipUnlessIntegrationEndpointReachable probes the endpoint an integration is
// actually configured with — the effective value of key on that integration's
// settings page — and skips when it does not answer. It is the generic half of
// the pattern skipUnlessForumReachable applies to Discourse: a walkthrough may
// be pointed at a compose profile (or an external service) that simply is not
// composed, and every service-touching phase would then fail on connection
// errors instead of skipping.
//
// Three outcomes, deliberately distinguished:
// - no effective value rendered → return, so a walkthrough's config-plumbing
// phases still run against an integration nobody configured;
// - configured but refusing connections → skip;
// - configured but answering 5xx → skip (container up, service still booting
// behind its proxy).
//
// Reading the URL off the settings page rather than off test/.env keeps the
// probe truthful for every bootstrap — compose profile, fake server, or a
// service hosted outside compose entirely. Leaves the page on the settings
// surface; callers must navigate onward themselves.
// hint names, in the caller's own words, what would make the endpoint answer.
func skipUnlessIntegrationEndpointReachable(t *testing.T, page *rod.Page, base, integrationKey, key, hint string) {
t.Helper()
page.Timeout(15 * time.Second).MustNavigate(base + "/operator/integrations/" + integrationKey + "/settings")
row := page.Timeout(10 * time.Second).MustElement(`#key-` + key)
// The effective value renders as <code> in the row's second cell; the
// first cell's <code> is the key name and its usage text may contain an
// example URL, so scrape the cell, not the row text.
has, code, err := row.Has(`td:nth-child(2) code`)
if err != nil || !has {
return
}
endpoint := strings.TrimSpace(code.MustText())
if !strings.HasPrefix(endpoint, "http") {
return
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(endpoint)
if err != nil {
t.Skipf("%s unreachable at %s: %v — %s", integrationKey, endpoint, err, hint)
}
resp.Body.Close()
if resp.StatusCode >= 500 {
t.Skipf("%s at %s answered %d — still booting, or a proxy fronting a down backend", integrationKey, endpoint, resp.StatusCode)
}
}
// skipUnlessFarmReachable is the FedWiki binding of the guard above. The farm
// API URL is the endpoint every FedWiki surface dials, and the `fedwiki`
// compose profile is what composes a farm to answer it.
func skipUnlessFarmReachable(t *testing.T, page *rod.Page, base string) {
t.Helper()
skipUnlessIntegrationEndpointReachable(t, page, base, "fedwiki", "fedwiki-farm-api-url",
`add "fedwiki" to COMPOSE_PROFILES in test/.env, bring the stack up, and uncomment the MC_FEDWIKI_* block`)
}
// newBrowser launches the rod browser (test/e2e/browsertest).
func newBrowser(t *testing.T) *rod.Browser {
t.Helper()
return browsertest.NewBrowser(t)
}
// openAllDisclosures opens every disclosure inside the operator main
// region — native <details> and Bootstrap .collapse panels alike. The org
// composite's mutation forms (Issue grant in the Plan-and-grants card,
// Extend on each pool's tier line) sit collapsed behind progressive
// disclosure (ux-operator-scale review rounds 2026-08-23); rod can locate
// elements inside a hidden panel but cannot interact with them, so
// walkthroughs open the disclosures the way an operator would before
// driving the forms.
func openAllDisclosures(page *rod.Page) {
page.MustEval(`() => {
document.querySelectorAll('#operator-main details').forEach(d => { d.open = true; });
document.querySelectorAll('#operator-main .collapse').forEach(c => { c.classList.add('show'); });
}`)
}
// gotoFirstOrgComposite navigates to /operator/organizations and follows
// the first "Manage" row-action link, landing on the per-org composite view
// (retitled: h1 = org display name; breadcrumb "Organizations → {name}").
// Returns the org's UUID extracted from the URL. Skips the test if no
// organizations are present.
func gotoFirstOrgComposite(t *testing.T, page *rod.Page, baseURL string) string {
t.Helper()
page.Timeout(15 * time.Second).MustNavigate(baseURL + "/operator/organizations")
links := page.MustElements(`a[href^="/operator/organizations/"]`)
if len(links) == 0 {
t.Skip("no organizations seeded; cannot exercise grant flows")
}
href := links[0].MustAttribute("href")
if href == nil || *href == "" {
t.Fatal("first organization link has empty href")
}
page.Timeout(15 * time.Second).MustNavigate(baseURL + *href)
page.Timeout(15 * time.Second).MustElement(`#operator-main`)
openAllDisclosures(page)
parts := strings.Split(strings.TrimSuffix(*href, "/"), "/")
return parts[len(parts)-1]
}
// gotoExtendableOrgComposite walks the operator org listing and lands on the
// first org whose composite renders a grant-extend form, returning that form.
// The form renders only for pools with an active grant-backed delivery (the
// extend handler's precondition), so its presence is a truthful signal that
// submitting can succeed. Scanning instead of taking the first org makes the
// walkthrough independent of what state earlier runs, other walkthroughs, or
// purchase flows left the listing in — a subscription-backed or revoked first
// org no longer poisons the run. Skips when no org qualifies.
func gotoExtendableOrgComposite(t *testing.T, page *rod.Page, baseURL string) (string, *rod.Element) {
t.Helper()
page.Timeout(15 * time.Second).MustNavigate(baseURL + "/operator/organizations")
links := page.MustElements(`a[href^="/operator/organizations/"]`)
if len(links) == 0 {
t.Skip("no organizations seeded; cannot exercise grant flows")
}
// Collect hrefs up front: the elements go stale once we navigate away.
seen := make(map[string]bool)
hrefs := make([]string, 0, len(links))
for _, l := range links {
href := l.MustAttribute("href")
if href == nil || *href == "" || seen[*href] {
continue
}
seen[*href] = true
hrefs = append(hrefs, *href)
}
for _, href := range hrefs {
page.Timeout(15 * time.Second).MustNavigate(baseURL + href)
main := page.Timeout(15 * time.Second).MustElement(`#operator-main`)
// Cheap containment check first so absent forms don't burn a
// MustElementX timeout per org.
if !strings.Contains(main.MustHTML(), "/grant/extend") {
continue
}
openAllDisclosures(page)
form := page.Timeout(10 * time.Second).MustElementX(`//form[contains(@hx-post, "/grant/extend")]`)
parts := strings.Split(strings.TrimSuffix(href, "/"), "/")
return parts[len(parts)-1], form
}
t.Skip("no org has an extendable (grant-backed) position; run test/seed-demo.sh for a fresh subject")
return "", nil
}
// loginAsOperator signs in as alice and lands on startPath
// (test/e2e/browsertest).
func loginAsOperator(t *testing.T, page *rod.Page, baseURL, startPath string) {
t.Helper()
browsertest.LoginAsOperator(t, page, baseURL, startPath)
}