Files
Felis/cmd/felis/tui_height_measure_test.go
T

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