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>
260 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|