CI / test (push) Failing after 30m4s
The setup screens list the parts of an install: control plane, store, model mirror, this machine as a node, starter model, pictures and 3D, the judge, firewall, notifications, limits, on-demand turns and an offline bundle. Each says what it is or what is missing without it. Space turns a part on or off; Enter opens its settings in a dialog that explains it. A plan page lists what is installed and what is left out, and the same install as a command. Every setting is a server init flag too, written into server.yaml when init writes it. With three machines, the two database machines become control planes too, standing by (server add's steps), with an optional floating address. The mirror's management API answers all three control planes, so a standby that comes to lead can still fetch models. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
272 lines
10 KiB
Go
272 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
func typeIn(m tea.Model, s string) tea.Model {
|
|
return send(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)})
|
|
}
|
|
|
|
// toPart moves the focus in the list of parts to the one titled so.
|
|
func toPart(t *testing.T, m tea.Model, title string) tea.Model {
|
|
t.Helper()
|
|
f := m.(initModel).choices
|
|
for i := 0; i <= len(f.parts); i++ {
|
|
if f.focus < len(f.parts) && f.parts[f.focus].title == title {
|
|
return m
|
|
}
|
|
m = press(m, "down")
|
|
}
|
|
t.Fatalf("no part %q", title)
|
|
return m
|
|
}
|
|
|
|
// toNext moves the focus to the button that goes on, and presses it.
|
|
func toNext(m tea.Model) tea.Model {
|
|
f := m.(initModel).choices
|
|
for f.focus != len(f.parts) {
|
|
m = press(m, "down")
|
|
}
|
|
return press(m, "enter")
|
|
}
|
|
|
|
func TestTheInitFormBecomesInitsSettings(t *testing.T) {
|
|
in := &serverInit{pkiDir: "/etc/llmgw/pki"}
|
|
var m tea.Model = newInitModel(in, []string{"10.0.0.140"}, true)
|
|
m = press(toPart(t, m, "Control plane"), "enter")
|
|
m = typeIn(m, "ops@example.com")
|
|
m = press(m, "tab")
|
|
if v := m.View(); !strings.Contains(v, "Already in its certificate: 10.0.0.140") {
|
|
t.Fatalf("the addresses found are not shown:\n%s", v)
|
|
}
|
|
m = typeIn(m, "llm.example.com, 203.0.113.10")
|
|
m = press(m, "esc")
|
|
m = press(toPart(t, m, "Store"), "enter", "right", "esc") // PostgreSQL
|
|
// Turning notifications on asks for the webhook at once.
|
|
m = press(toPart(t, m, "Notifications"), "space")
|
|
if m.(initModel).choices.dialog == nil {
|
|
t.Fatal("notifications turned on without asking where")
|
|
}
|
|
m = typeIn(m, "https://hooks.slack.com/services/T1/B2/x, json:https://alerts.example.com/in")
|
|
m = press(m, "esc")
|
|
m = press(toPart(t, m, "Starter model"), "space") // off
|
|
m = press(toPart(t, m, "Guardrails' judge"), "space") // off
|
|
m = toNext(m)
|
|
if !m.(initModel).plan {
|
|
t.Fatalf("Next did not lead to the plan: %s", m.(initModel).choices.err)
|
|
}
|
|
|
|
if in.admin != "ops@example.com" || strings.Join(in.hosts, "|") != "llm.example.com|203.0.113.10" {
|
|
t.Errorf("admin %q hosts %q", in.admin, in.hosts)
|
|
}
|
|
if !in.postgres || in.noMirror || in.noNode || !in.noModel || !in.noGuardModel {
|
|
t.Errorf("toggles: postgres %v mirror %v node %v model %v", in.postgres, !in.noMirror, !in.noNode, !in.noModel)
|
|
}
|
|
if hooks, err := parseWebhooks(in.webhooks); err != nil || len(hooks) != 2 || hooks[0].Format != "slack" || hooks[1].Format != "json" {
|
|
t.Errorf("webhooks %q: %+v %v", in.webhooks, hooks, err)
|
|
}
|
|
v := m.View()
|
|
for _, want := range []string{"✓ Store", "PostgreSQL on this machine", "Left out", "Starter model", "--postgres", "--no-model"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("the plan lacks %q:\n%s", want, v)
|
|
}
|
|
}
|
|
m = press(m, "enter")
|
|
if m.(initModel).form {
|
|
t.Error("the form stayed up after the plan was agreed to")
|
|
}
|
|
}
|
|
|
|
func TestAWebhookThatIsNoAddressKeepsTheFormUp(t *testing.T) {
|
|
in := &serverInit{}
|
|
var m tea.Model = newInitModel(in, nil, true)
|
|
m = press(toPart(t, m, "Notifications"), "space")
|
|
m = typeIn(m, "hooks.slack.com/services/x")
|
|
m = toNext(press(m, "esc"))
|
|
im := m.(initModel)
|
|
if !im.form || im.plan || !strings.Contains(m.View(), "http(s) address") {
|
|
t.Fatalf("an address without its scheme was taken:\n%s", m.View())
|
|
}
|
|
// Closing the message opens the field that is wrong.
|
|
m = press(m, "q")
|
|
d := m.(initModel).choices.dialog
|
|
if d == nil || d.Fields[d.focus] != m.(initModel).choices.webhooks {
|
|
t.Errorf("the webhook was not given the cursor")
|
|
}
|
|
}
|
|
|
|
func TestAnOfflineBundleIsGivenInTheFormWithItsDigest(t *testing.T) {
|
|
in := &serverInit{}
|
|
var m tea.Model = newInitModel(in, nil, true)
|
|
if v := m.View(); !strings.Contains(v, "Offline bundle") {
|
|
t.Fatalf("the form offers no bundle:\n%s", v)
|
|
}
|
|
m = press(toPart(t, m, "Offline bundle"), "space")
|
|
m = typeIn(m, "/media/usb/b.tar")
|
|
m = toNext(press(m, "esc"))
|
|
if m.(initModel).plan || !strings.Contains(m.View(), "sha256") {
|
|
t.Fatalf("a bundle without its digest was accepted:\n%s", m.View())
|
|
}
|
|
m = press(m, "q") // the message closed, the digest has the cursor
|
|
m = typeIn(m, strings.Repeat("AB", 32))
|
|
m = toNext(press(m, "esc"))
|
|
if !m.(initModel).plan || in.bundle != "/media/usb/b.tar" || in.bundleSHA != strings.Repeat("ab", 32) {
|
|
t.Errorf("bundle %q sha %q, plan %v: %s", in.bundle, in.bundleSHA, m.(initModel).plan, m.(initModel).choices.err)
|
|
}
|
|
}
|
|
|
|
func TestAnEmailThatIsNotOneIsSaidSo(t *testing.T) {
|
|
in := &serverInit{}
|
|
var m tea.Model = newInitModel(in, nil, true)
|
|
m = press(m, "enter") // the control plane, first
|
|
m = typeIn(m, "not an address")
|
|
m = toNext(press(m, "esc"))
|
|
if m.(initModel).plan || !strings.Contains(m.View(), "not an address") {
|
|
t.Errorf("a bad email was accepted:\n%s", m.View())
|
|
}
|
|
}
|
|
|
|
func TestEachPartsSettingsReachInit(t *testing.T) {
|
|
in := &serverInit{}
|
|
var m tea.Model = newInitModel(in, nil, true)
|
|
m = press(toPart(t, m, "Control plane"), "enter", "tab", "tab", "right", "tab", "right", "esc") // Türkçe, sign-up open
|
|
// The starter: qwen3-8b, the fifth, with 8192 tokens.
|
|
m = press(toPart(t, m, "Starter model"), "enter", "right", "right", "right", "right", "tab")
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
|
|
m = press(typeIn(m, "8192"), "esc")
|
|
// The judge: serving flagged, three copies, 3s, and PII too.
|
|
m = press(toPart(t, m, "Guardrails' judge"), "enter", "right", "tab")
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
|
|
m = press(typeIn(m, "3"), "tab")
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
|
|
m = typeIn(m, "3s")
|
|
for range 6 { // past the five ticked, to PII
|
|
m = press(m, "tab")
|
|
}
|
|
m = press(m, "space", "esc")
|
|
m = press(toPart(t, m, "Pictures and 3D"), "space")
|
|
m = press(toPart(t, m, "Limits and keys"), "enter")
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
|
|
m = press(typeIn(m, "120"), "tab", "tab")
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
|
|
m = press(typeIn(m, "30"), "esc")
|
|
m = press(toPart(t, m, "Taking turns on a card"), "enter")
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
|
|
m = press(typeIn(m, "1m"), "tab")
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
|
|
m = press(typeIn(m, "1m"), "esc")
|
|
m = press(toPart(t, m, "Model mirror"), "enter", "tab")
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
|
|
m = press(typeIn(m, "/data/models"), "esc")
|
|
m = toNext(m)
|
|
if !m.(initModel).plan {
|
|
t.Fatalf("not taken: %s", m.(initModel).choices.err)
|
|
}
|
|
switch {
|
|
case in.language != "tr" || !in.signupOpen:
|
|
t.Errorf("language %q signup %v", in.language, in.signupOpen)
|
|
case in.starter.Name != "qwen3-8b" || in.starterCtx != 8192:
|
|
t.Errorf("starter %q ctx %d", in.starter.Name, in.starterCtx)
|
|
case in.judge.Fail != "open" || in.judge.Replicas != 3 || in.judge.Budget != 3*time.Second ||
|
|
len(in.judge.Categories) != 6 || in.judge.Categories[5] != "PII":
|
|
t.Errorf("judge %+v", in.judge)
|
|
case !in.media:
|
|
t.Error("pictures and 3D not taken")
|
|
case in.defaultRPM != 120 || in.defaultTPM != defaultTPM || in.maxKeyTTL != 30*24*time.Hour:
|
|
t.Errorf("limits %d %d %s", in.defaultRPM, in.defaultTPM, in.maxKeyTTL)
|
|
case in.evictAfterIdle != time.Minute || in.minLifetime != time.Minute:
|
|
t.Errorf("turns %s %s", in.evictAfterIdle, in.minLifetime)
|
|
case in.mirrorPath != "/data/models":
|
|
t.Errorf("mirror %q", in.mirrorPath)
|
|
}
|
|
v := m.View()
|
|
for _, want := range []string{"--starter qwen3-8b", "--starter-context 8192", "--judge-fail open", "--judge-replicas 3",
|
|
"--language tr", "--media", "--mirror-dir /data/models", "--evict-after-idle 1m", "--max-key-ttl 720h"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("the command lacks %q:\n%s", want, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAStarterModelWantsTheMirror(t *testing.T) {
|
|
in := &serverInit{}
|
|
var m tea.Model = newInitModel(in, nil, true)
|
|
m = toNext(press(toPart(t, m, "Model mirror"), "space"))
|
|
if m.(initModel).plan || !strings.Contains(m.View(), "turn the mirror on") {
|
|
t.Errorf("a starter model with no mirror to fetch it into was taken:\n%s", m.View())
|
|
}
|
|
}
|
|
|
|
func TestALongDialogScrollsWithItsFocus(t *testing.T) {
|
|
in := &serverInit{}
|
|
m := newInitModel(in, nil, true)
|
|
m.choices.height = 24
|
|
var tm tea.Model = press(toPart(t, m, "Guardrails' judge"), "enter")
|
|
_, top := tm.(initModel).choices.overlay()
|
|
if !strings.Contains(top, "↓ more") || strings.Contains(top, "Copyright Violation") {
|
|
t.Fatalf("a dialog taller than the screen is not cut:\n%s", top)
|
|
}
|
|
for range 11 {
|
|
tm = press(tm, "tab")
|
|
}
|
|
if _, bottom := tm.(initModel).choices.overlay(); !strings.Contains(bottom, "Copyright Violation") {
|
|
t.Errorf("the focus went where it cannot be seen:\n%s", bottom)
|
|
}
|
|
}
|
|
|
|
func TestInitsLinesAreFiledAndThePasswordKeptInView(t *testing.T) {
|
|
in := &serverInit{admin: "ops@example.com", pkiDir: "/etc/llmgw/pki"}
|
|
var m tea.Model = newInitModel(in, nil, false)
|
|
for _, l := range []string{
|
|
" ok system user llmgw",
|
|
" kept certificate authority in /etc/llmgw/pki",
|
|
" ok administrator ops@example.com (u1)",
|
|
"",
|
|
" password: Tr0ub4dor-3",
|
|
" shown once — sign in and change it from /profile",
|
|
"",
|
|
" .. downloading qwen2.5-0.5b-instruct into the mirror",
|
|
" .. 40%",
|
|
" .. 80%",
|
|
" !! an NVIDIA card is on the PCI bus but nvidia-smi does not answer",
|
|
"The control plane is up. Next:",
|
|
" llmgw ctl status",
|
|
} {
|
|
m = send(m, initLineMsg(l))
|
|
}
|
|
m = send(m, initDoneMsg{})
|
|
im := m.(initModel)
|
|
if im.password != "Tr0ub4dor-3" {
|
|
t.Errorf("password %q", im.password)
|
|
}
|
|
v := m.View()
|
|
for _, want := range []string{"✓ system user llmgw", "Tr0ub4dor-3", "Shown once", "80%", "! an NVIDIA card", "Back up /etc/llmgw/pki", "node add"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("screen lacks %q:\n%s", want, v)
|
|
}
|
|
}
|
|
if strings.Contains(v, "40%") {
|
|
t.Error("progress lines piled up instead of replacing each other")
|
|
}
|
|
if strings.Contains(v, "llmgw ctl status") {
|
|
t.Error("the closing text was listed as a step")
|
|
}
|
|
}
|
|
|
|
func TestWithAMirrorElsewhereTheFormSaysSo(t *testing.T) {
|
|
in := &serverInit{mirrorURL: "http://10.0.0.150:8082"}
|
|
var m tea.Model = newInitModel(in, nil, true)
|
|
if v := m.View(); !strings.Contains(v, "at http://10.0.0.150:8082") {
|
|
t.Errorf("the mirror in use is not shown:\n%s", v)
|
|
}
|
|
m = toNext(m)
|
|
if !m.(initModel).plan || in.mirrorURL != "http://10.0.0.150:8082" || !in.noMirror {
|
|
t.Errorf("mirror %q noMirror %v: %s", in.mirrorURL, in.noMirror, m.(initModel).choices.err)
|
|
}
|
|
}
|