179 lines
7.2 KiB
Go
179 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// TestWizardViewsFitTerminal guards against the regression that motivated the
|
|
// redesign: a composed view taller than the terminal makes bubbletea clip the
|
|
// top (the step rail) and read as janky. After a WindowSizeMsg the root budgets
|
|
// height across the rail and the active screen, so no stage's rendered view may
|
|
// exceed the terminal height.
|
|
func TestWizardViewsFitTerminal(t *testing.T) {
|
|
stages := []struct {
|
|
name string
|
|
msg tea.Msg
|
|
}{
|
|
{"owner", preflightDoneMsg{}},
|
|
{"connect", ownerResultMsg{username: "owner", displayPassword: "hunter2pw"}},
|
|
{"summary", connectResultMsg{method: connectLocal, panelHostname: "panel.example.com"}},
|
|
}
|
|
|
|
// Sweep widths too: narrow terminals wrap the long notes (e.g. the connect
|
|
// chooser's security warning), which is exactly where height can creep back
|
|
// over budget. 60 is about as narrow as a real terminal gets.
|
|
for _, w := range []int{60, 80, 90} {
|
|
for _, h := range []int{24, 30, 45} {
|
|
var m tea.Model = newTestRoot(false, consoleModeSetup, "")
|
|
m, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: h})
|
|
for _, s := range stages {
|
|
m, _ = m.Update(s.msg)
|
|
got := lipgloss.Height(m.(*rootModel).View())
|
|
if got > h {
|
|
t.Errorf("terminal %dx%d: %s stage view = %d rows (exceeds height)", w, h, s.name, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEdgeFormFitsTerminal covers the screen the existing sweep can't reach: the
|
|
// Cloudflare edge form. The chooser only adopts the edge model when "Cloudflare"
|
|
// is picked, and its form only opens once cloudflared + the login cert are
|
|
// present — neither is available in CI — so the connectLocal sweep above never
|
|
// constructs it. Here we adopt a cloudflared-satisfied edge model through the
|
|
// root (so the real step-rail chrome is in the budget) and measure BOTH form
|
|
// groups: credentials (group 1) and the taller hostnames group (group 2, five
|
|
// inputs). huh clamps each group to a scrolling viewport, but a tall group title
|
|
// or help footer can still push the composed view past the terminal — which is
|
|
// exactly the clipping regression this guards.
|
|
func TestEdgeFormFitsTerminal(t *testing.T) {
|
|
for _, w := range []int{60, 80, 90} {
|
|
for _, h := range []int{24, 30, 45} {
|
|
root := newTestRoot(false, consoleModeSetup, "")
|
|
root = drive(t, root, tea.WindowSizeMsg{Width: w, Height: h})
|
|
root.stage = stageConnect
|
|
|
|
edge := newEdgeModel("felis.example.com", "admin.felis.example.com", "panel.felis.example.com")
|
|
// Pretend the operator already installed cloudflared + logged in so the
|
|
// intro lets us open the form without a live cloudflared/cert.
|
|
edge.cloudflaredPath = "/usr/local/bin/cloudflared"
|
|
edge.certExists = true
|
|
root.adopt(edge) // sizes the edge model with the post-rail budget
|
|
|
|
// intro → form (group 1: credentials)
|
|
root = drive(t, root, key(tea.KeyEnter))
|
|
if edge.step != egForm {
|
|
t.Fatalf("terminal %dx%d: enter on a ready intro should open the form, step = %v", w, h, edge.step)
|
|
}
|
|
// huh clamps every group to a scrolling viewport sized to the budget, so
|
|
// the rendered height is the same whether the viewport content has been
|
|
// built yet or not — measuring the freshly-opened frame is a faithful
|
|
// height check. The group title renders outside the viewport, so it is
|
|
// present even before content builds, which is how we confirm we are on
|
|
// the right group.
|
|
if v := root.View(); !strings.Contains(v, "Step 1 · Credentials") {
|
|
t.Fatalf("terminal %dx%d: form should open on the credentials group, got:\n%s", w, h, v)
|
|
}
|
|
if got := lipgloss.Height(root.View()); got > h {
|
|
t.Errorf("terminal %dx%d: edge credentials view = %d rows (exceeds height)", w, h, got)
|
|
}
|
|
|
|
// Advance to the taller hostnames group (5 inputs). huh advances groups
|
|
// natively when the current group has no errors; NextGroup mutates the
|
|
// form in place, and edge holds the same form pointer the root renders.
|
|
edge.form.NextGroup()
|
|
if v := root.View(); !strings.Contains(v, "Step 2 · Hostnames & identity") {
|
|
t.Fatalf("terminal %dx%d: NextGroup should reveal the hostnames group, got:\n%s", w, h, v)
|
|
}
|
|
if got := lipgloss.Height(root.View()); got > h {
|
|
t.Errorf("terminal %dx%d: edge hostnames view = %d rows (exceeds height)", w, h, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEdgeValidators locks the input-validation logic the edge form relies on —
|
|
// the part that decides whether a friend's real run is accepted or rejected
|
|
// before any Cloudflare call. These are the validators wired into the huh fields;
|
|
// testing them directly is honest coverage that does not depend on driving huh.
|
|
func TestEdgeValidators(t *testing.T) {
|
|
accountCases := []struct {
|
|
in string
|
|
ok bool
|
|
}{
|
|
{"", false},
|
|
{"0123456789abcdef0123456789abcdef", true},
|
|
{"0123456789ABCDEF0123456789abcdef", true},
|
|
{"cfat_looks_like_a_token", false}, // token pasted into the account field
|
|
{"too-short", false},
|
|
{"0123456789abcdef0123456789abcde", false}, // 31 chars
|
|
}
|
|
for _, c := range accountCases {
|
|
if err := validateAccountID(c.in); (err == nil) != c.ok {
|
|
t.Errorf("validateAccountID(%q): ok=%v, err=%v", c.in, c.ok, err)
|
|
}
|
|
}
|
|
|
|
identityCases := []struct {
|
|
in string
|
|
ok bool
|
|
}{
|
|
{"", false},
|
|
{"@", false}, // bare @ with no domain
|
|
{"[email protected]", true},
|
|
{"@your-domain.com", true},
|
|
}
|
|
for _, c := range identityCases {
|
|
if err := validateAccessIdentity(c.in); (err == nil) != c.ok {
|
|
t.Errorf("validateAccessIdentity(%q): ok=%v, err=%v", c.in, c.ok, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEdgeFormExitKeys locks the form's exit keys, which huh cannot disambiguate
|
|
// on its own: it collapses esc and ctrl+c into a single StateAborted, so the edge
|
|
// model must intercept them before delegating. esc steps back to the intro/status
|
|
// screen (matching reverseProxyModel's esc=back); ctrl+c quits like every sibling
|
|
// screen. Without this, ctrl+c from the form would fall through huh's abort path
|
|
// and return to the intro instead of quitting, and a failed setup could strand the
|
|
// user on the form. The keys only matter live, so the contract lives here.
|
|
func TestEdgeFormExitKeys(t *testing.T) {
|
|
open := func(t *testing.T) *edgeModel {
|
|
t.Helper()
|
|
e := newEdgeModel("felis.example.com", "admin.felis.example.com", "panel.felis.example.com")
|
|
e.cloudflaredPath = "/usr/local/bin/cloudflared"
|
|
e.certExists = true
|
|
next, _ := e.Update(key(tea.KeyEnter)) // ready intro → form
|
|
em, ok := next.(*edgeModel)
|
|
if !ok {
|
|
t.Fatalf("intro enter returned %T, want *edgeModel", next)
|
|
}
|
|
if em.step != egForm {
|
|
t.Fatalf("intro enter should open the form, step = %v", em.step)
|
|
}
|
|
return em
|
|
}
|
|
|
|
// esc steps back to the intro/status screen — not quit, not stuck on the form.
|
|
e := open(t)
|
|
next, _ := e.Update(key(tea.KeyEsc))
|
|
if got := next.(*edgeModel).step; got != egIntro {
|
|
t.Fatalf("esc on the edge form should return to the intro, step = %v", got)
|
|
}
|
|
|
|
// ctrl+c quits, like every sibling screen.
|
|
e = open(t)
|
|
_, cmd := e.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
|
if cmd == nil {
|
|
t.Fatal("ctrl+c on the edge form should return a command (tea.Quit)")
|
|
}
|
|
if msg := cmd(); !isQuit(msg) {
|
|
t.Fatalf("ctrl+c command = %T, want tea.Quit", msg)
|
|
}
|
|
}
|