- 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
195 lines
8.3 KiB
Go
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)
|
|
}
|