Files
llmgw/internal/cli/tui_actions_test.go
T
Ilker ManapandClaude Opus 5.5 eb7dac8a67 The console manages people, keys, organizations and the mirror, and scrolls a tall sidebar
Accounts, API keys and Organizations views read the store and change it by
running the users, keys and org commands in-process, so their checks and
messages are the ones shown. A Mirror view lists what the mirror holds and
which models use it, downloads into it and removes from it. Models gain
adding from a definition file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:30:24 +02:00

260 lines
7.4 KiB
Go

package cli
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
type seen struct {
mu sync.Mutex
reqs []string
}
func (s *seen) last() string {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.reqs) == 0 {
return ""
}
return s.reqs[len(s.reqs)-1]
}
// adminStub answers every change with 204 and records it as "METHOD path body".
func adminStub(t *testing.T) (*seen, string) {
s := &seen{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte("[]"))
return
}
b, _ := io.ReadAll(r.Body)
s.mu.Lock()
s.reqs = append(s.reqs, r.Method+" "+r.URL.Path+" "+strings.TrimSpace(string(b)))
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
return s, srv.URL
}
// pressRun presses keys and runs, one level deep, whatever the last one
// returns that is not a tick, the way the program would.
func pressRun(m tea.Model, keys ...string) tea.Model {
m = press(m, keys[:len(keys)-1]...)
var msg tea.Msg = tea.KeyMsg{Type: tea.KeyEnter}
if k := keys[len(keys)-1]; k != "enter" {
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}
}
m, cmd := m.Update(msg)
if cmd != nil {
if done, ok := cmd().(actionDoneMsg); ok {
m, _ = m.Update(done)
}
}
return m
}
func consoleWith(t *testing.T, menu int) (consoleModel, *seen) {
s, url := adminStub(t)
m := newConsoleModel(url, "/nonexistent/server.yaml")
m.loaded, m.menu, m.inPanel = true, menu, true
m.data.models = []adminapi.ModelView{{Name: "qwen", Min: 1, Max: 1, Class: "standard", Profiles: []string{"cpu"}}}
m.data.nodes = []adminapi.NodeView{{ID: "n1", State: "ready"}}
m.data.replicas = []adminapi.ReplicaView{{ID: "qwen-1", Model: "qwen"}}
return m, s
}
func TestAModelsReplicasAreSetFromItsRow(t *testing.T) {
m, s := consoleWith(t, cModels)
var tm tea.Model = m
tm = press(tm, "enter")
if !strings.Contains(tm.View(), "Replicas (scale)") {
t.Fatalf("no action menu:\n%s", tm.View())
}
tm = press(tm, "down", "enter")
f := tm.(consoleModel).formD
if f == nil {
t.Fatal("no form")
}
f.fields[1].text.SetValue("3")
// A max below the min is refused in the form, and nothing is sent.
f.fields[0].text.SetValue("5")
tm = press(tm, "tab", "tab", "tab", "enter")
if f.err == "" || s.last() != "" {
t.Fatalf("a max below the min went through: %q %q", f.err, s.last())
}
f.fields[0].text.SetValue("2")
tm = pressRun(tm, "enter")
var body adminapi.ScaleRequest
got := s.last()
if !strings.HasPrefix(got, "POST /api/v1/models/qwen/scale ") ||
json.Unmarshal([]byte(strings.SplitN(got, " ", 3)[2]), &body) != nil || body.Min != 2 || body.Max != 3 {
t.Fatalf("sent %q", got)
}
if !strings.Contains(tm.View(), "keeps 2 to 3") {
t.Errorf("the outcome was not shown:\n%s", tm.View())
}
}
func TestANodeIsCordonedAndAModelRemovedOnlyWhenConfirmed(t *testing.T) {
m, s := consoleWith(t, cNodes)
var tm tea.Model = m
tm = pressRun(tm, "enter", "down", "enter")
if got := s.last(); got != `POST /api/v1/nodes/n1/state {"state":"cordoned"}` {
t.Fatalf("sent %q", got)
}
m, s = consoleWith(t, cModels)
tm = m
tm = press(tm, "enter")
n := len(tm.(consoleModel).menuD.items)
keys := []string{}
for range n - 1 {
keys = append(keys, "down")
}
tm = press(tm, append(keys, "enter")...)
if tm.(consoleModel).confirmD == nil {
t.Fatal("removing did not ask first")
}
tm = press(tm, "n")
if s.last() != "" {
t.Fatalf("removed though the answer was no: %q", s.last())
}
tm = press(tm, "enter")
tm = press(tm, append(keys, "enter")...)
_ = pressRun(tm, "y")
if got := s.last(); got != "DELETE /api/v1/models/qwen " {
t.Fatalf("sent %q", got)
}
}
func TestAQuotaIsAddedWithOnlyWhatIsGiven(t *testing.T) {
m, s := consoleWith(t, cQuotas)
var tm tea.Model = press(m, "a")
f := tm.(consoleModel).formD
f.fields[0].text.SetValue("team:acme/ml")
f.fields[4].text.SetValue("2")
for range len(f.fields) {
tm = press(tm, "tab")
}
_ = pressRun(tm, "enter")
if got := s.last(); got != `PUT /api/v1/quotas {"scope":"team:acme/ml","guarantee":{},"limit":{"gpus":2}}` {
t.Fatalf("sent %q", got)
}
}
func TestPeopleAreManagedFromTheConsoleAgainstItsStore(t *testing.T) {
dir := t.TempDir()
cfgPath := dir + "/server.yaml"
yaml := "auth:\n store: " + dir + "/llmgw.db\n"
if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
if _, err := config.Load(cfgPath); err != nil {
t.Fatalf("the test's configuration: %v", err)
}
m := newConsoleModel("127.0.0.1:1", cfgPath)
defer m.st.close()
m.loaded, m.inPanel = true, true
refresh := func(tm tea.Model) tea.Model {
cm := tm.(consoleModel)
msg := cm.fetchPeople()()
tm, _ = tm.Update(msg)
return tm
}
submit := func(tm tea.Model, vals ...string) tea.Model {
f := tm.(consoleModel).formD
if f == nil {
t.Fatal("no form")
}
for i, v := range vals {
f.fields[i].text.SetValue(v)
}
f.focus = len(f.fields)
tm = pressRun(tm, "enter")
if v := tm.View(); !strings.Contains(v, "✓") {
t.Fatalf("did not work:\n%s", v)
}
return refresh(press(tm, "x"))
}
m.menu = cOrgs
var tm tea.Model = m
tm = submit(press(tm, "a"), "Acme")
// Into the organization: a team, then a project in it.
tm = submit(press(tm, "enter", "enter"), "ml")
cm := tm.(consoleModel)
if len(cm.people.orgs) != 2 || cm.people.orgs[1].path != "Acme/ml" {
t.Fatalf("tree: %+v", cm.people.orgs)
}
tm = submit(press(tm, "down", "enter", "enter"), "training")
cm = tm.(consoleModel)
cm.menu, cm.row = cAccounts, 0
tm = submit(press(cm, "a"), "ayse@example.com", "a-long-s3cret-pass", "Acme/ml/training")
cm = tm.(consoleModel)
if len(cm.people.users) != 1 || cm.people.users[0].State != store.UserActive ||
!strings.Contains(strings.Join(cm.people.where[cm.people.users[0].ID], ","), "training") {
t.Fatalf("account: %+v %v", cm.people.users, cm.people.where)
}
cm.menu, cm.row = cKeys, 0
tm = press(cm, "a")
f := tm.(consoleModel).formD
f.fields[0].text.SetValue("ci")
f.fields[1].text.SetValue("Acme/ml/training")
f.focus = len(f.fields)
tm = pressRun(tm, "enter")
if v := tm.View(); !strings.Contains(v, "sk-") {
t.Fatalf("the new key was not shown:\n%s", v)
}
tm = refresh(press(tm, "x"))
if n := len(tm.(consoleModel).people.keys); n != 1 {
t.Fatalf("%d keys", n)
}
// Limits on the project, read back in its row.
cm = tm.(consoleModel)
cm.menu, cm.row = cOrgs, 2
tm = press(cm, "enter")
items := tm.(consoleModel).menuD.items
i := 0
for ; items[i].label != "Limits…"; i++ {
}
keys := []string{}
for range i {
keys = append(keys, "down")
}
tm = submit(press(tm, append(keys, "enter")...), "60")
if c := limitsCell(tm.(consoleModel).people.limits[store.LimitOwner{Scope: store.ScopeProject,
ID: tm.(consoleModel).people.orgs[2].id}.CounterKey()]); c != "60 rpm" {
t.Fatalf("limits: %q", c)
}
}
func TestTheSidebarFitsAShortScreen(t *testing.T) {
m, _ := consoleWith(t, cOrgs)
m.inPanel = false
for _, h := range []int{20, 30, 50} {
m.height = h
if n := len(strings.Split(m.View(), "\n")); n > h {
t.Errorf("height %d: %d lines drawn", h, n)
}
if !strings.Contains(m.View(), "Organizations") {
t.Errorf("height %d: the current entry is not shown", h)
}
}
}