Files
llmgw/internal/cli/tui_node_test.go
T
Ilker ManapandClaude Opus 5.5 a1b5daceb8 Let server init ask for what it needs, and node add take several nodes
In a terminal and without --admin, server init shows a short form: the
administrator's email, the other names nodes will use (with the addresses
it found), SQLite or PostgreSQL, and the mirror, node and starter model.
Its own lines then fill in as a list — read from the writer init already
uses, so nothing in init changed — with downloads updating in place and the
password in a box until the end. The whole run, password included, is
printed again when the screen closes, so it is in the terminal's history.
The last screen says how to add a node and to back up the authority's key.

node add takes several addresses and adds them in turn, with a summary.
Suggested fabrics carry what the adapters report them to be, so a name
given for one node is used for the next on the same fabric without asking.

Tried on empty VMs: init from the form through to the starter model
serving, and two nodes added to that cluster in one command. The first
attempt at the form lost its keystrokes to the terminal colour query in a
pseudo-terminal that never answers it; a real terminal does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:54:18 +02:00

118 lines
4.1 KiB
Go

package cli
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
)
func send(m tea.Model, msgs ...tea.Msg) tea.Model {
for _, msg := range msgs {
m, _ = m.Update(msg)
}
return m
}
func TestTheNodeScreenFillsInAsStepsFinish(t *testing.T) {
var m tea.Model = newNodeModel("adding gpu01", func() {})
m = send(m,
evMsg{Step: onboard.StepConnect, Level: onboard.Done, Detail: "gpu01 (amd64)"},
evMsg{Step: onboard.StepCheck, Level: onboard.Warn, Detail: "fabric: nvidia_peermem is not loaded", Fix: "modprobe nvidia_peermem"},
evMsg{Step: onboard.StepCheck, Level: onboard.Running, Detail: "running llmgw doctor"},
)
v := m.View()
for _, want := range []string{"✓", "gpu01 (amd64)", "nvidia_peermem is not loaded", "→ modprobe nvidia_peermem", "running llmgw doctor", "engines"} {
if !strings.Contains(v, want) {
t.Errorf("screen lacks %q:\n%s", want, v)
}
}
}
func TestTheFabricQuestionAnswersWithWhatWasTyped(t *testing.T) {
var m tea.Model = newNodeModel("adding gpu01", func() {})
reply := make(chan []onboard.Fabric, 1)
m = send(m, askFabricsMsg{suggested: []onboard.Fabric{
{Name: "fabric1", HCAs: []string{"mlx5_0", "mlx5_1"}},
{Name: "fabric2", HCAs: []string{"mlx5_4"}},
}, reply: reply})
if v := m.View(); !strings.Contains(v, "Which fabric") || !strings.Contains(v, "mlx5_0, mlx5_1") {
t.Fatalf("no question on screen:\n%s", m.View())
}
// Rename the first to ib-a, empty the second to leave it out.
for range len("fabric1") {
m = send(m, tea.KeyMsg{Type: tea.KeyBackspace})
}
m = send(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ib-a")}, tea.KeyMsg{Type: tea.KeyTab})
for range len("fabric2") {
m = send(m, tea.KeyMsg{Type: tea.KeyBackspace})
}
m = send(m, tea.KeyMsg{Type: tea.KeyEnter})
got := <-reply
if len(got) != 1 || got[0].Name != "ib-a" || strings.Join(got[0].HCAs, ",") != "mlx5_0,mlx5_1" {
t.Errorf("got %+v", got)
}
if strings.Contains(m.View(), "Which fabric") {
t.Error("the question stayed on screen after it was answered")
}
}
func TestContinuingPastAProblemIsNoUnlessSaidYes(t *testing.T) {
var m tea.Model = newNodeModel("adding gpu01", func() {})
reply := make(chan bool, 1)
m = send(m, askContinueMsg{fails: 2, reply: reply}, tea.KeyMsg{Type: tea.KeyEnter})
if <-reply {
t.Error("enter went on past the problems")
}
m = send(m, askContinueMsg{fails: 2, reply: reply}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
if !<-reply {
t.Error("y did not go on")
}
_ = m
}
func TestANodeCountsAsJoinedOnlyWhenHeardSinceItsAgentStarted(t *testing.T) {
start := time.Now().Add(-10 * time.Second)
cases := []struct {
state, ago string
want bool
}{
{"ready", "3s", true},
{"cordoned", "3s", true}, // added again while cordoned
{"ready", "30s", false}, // the agent before the restart
{"unreachable", "3s", false},
{"ready", "", false},
}
for _, c := range cases {
if got := heardSince(adminapi.NodeView{State: c.state, LastSeenAgo: c.ago}, start); got != c.want {
t.Errorf("%s seen %q ago: got %v", c.state, c.ago, got)
}
}
}
func TestAFabricNamedForOneNodeIsNotAskedAgainForTheNext(t *testing.T) {
na := &nodeAdd{named: map[string]string{}}
asked := 0
ask := func(s []onboard.Fabric) []onboard.Fabric {
asked++
return []onboard.Fabric{{Name: "ib-compute", HCAs: s[0].HCAs, Key: s[0].Key}}
}
first := na.rememberFabrics(ask)([]onboard.Fabric{{Name: "fabric", HCAs: []string{"mlx5_0"}, Key: "sm:0xa"}})
second := na.rememberFabrics(ask)([]onboard.Fabric{{Name: "fabric", HCAs: []string{"mlx5_0", "mlx5_1"}, Key: "sm:0xa"}})
if asked != 1 {
t.Errorf("asked %d times", asked)
}
if len(second) != 1 || second[0].Name != "ib-compute" || len(second[0].HCAs) != 2 || first[0].Name != "ib-compute" {
t.Errorf("first %+v second %+v", first, second)
}
// A fabric not seen before is asked about; in a script nothing is asked.
if got := na.rememberFabrics(nil)([]onboard.Fabric{{Name: "fabric", Key: "sm:0xb"}}); len(got) != 0 {
t.Errorf("a script was given a fabric nobody named: %+v", got)
}
}