Every document and a page for every command, built from their help, read like info: topics, contents, find. ? opens the console's own page, which is new and covers every screen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
393 lines
12 KiB
Go
393 lines
12 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAModelIsFoundOnTheRegistryAndServedFromTheConsole(t *testing.T) {
|
|
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/api/models":
|
|
if r.URL.Query().Get("search") != "qwen" || r.URL.Query().Get("filter") != "gguf" {
|
|
_, _ = w.Write([]byte(`[]`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`[{"id":"bartowski/Qwen2.5-3B-Instruct-GGUF","downloads":1200,"gguf":{"total":3000000000,"architecture":"qwen2"}}]`))
|
|
case r.URL.Path == "/api/models/bartowski/Qwen2.5-3B-Instruct-GGUF":
|
|
_, _ = w.Write([]byte(`{"id":"bartowski/Qwen2.5-3B-Instruct-GGUF","sha":"abc"}`))
|
|
case strings.HasPrefix(r.URL.Path, "/api/models/bartowski/Qwen2.5-3B-Instruct-GGUF/tree/"):
|
|
_, _ = w.Write([]byte(`[{"type":"file","path":"Qwen2.5-3B-Instruct-Q4_K_M.gguf","lfs":{"oid":"x","size":2000000000}},
|
|
{"type":"file","path":"Qwen2.5-3B-Instruct-Q8_0.gguf","lfs":{"oid":"y","size":3000000000}},
|
|
{"type":"file","path":"mmproj-f16.gguf","size":10}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer reg.Close()
|
|
dir := t.TempDir()
|
|
cfgPath := dir + "/server.yaml"
|
|
if err := os.WriteFile(cfgPath, []byte("mirror:\n api: http://127.0.0.1:1\n registry: "+reg.URL+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := newConsoleModel("127.0.0.1:9090", cfgPath)
|
|
m.loaded, m.menu, m.inPanel = true, cServe, true
|
|
var tm tea.Model = m
|
|
|
|
run := func(tm tea.Model, cmd tea.Cmd) tea.Model {
|
|
if cmd != nil {
|
|
tm, _ = tm.Update(cmd())
|
|
}
|
|
return tm
|
|
}
|
|
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
|
|
tm = press(tm, "q", "w", "e", "n")
|
|
tm, cmd := tm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
tm = run(tm, cmd)
|
|
if v := tm.View(); !strings.Contains(v, "bartowski/Qwen2.5-3B-Instruct-GGUF") || !strings.Contains(v, "3.0B") {
|
|
t.Fatalf("no result:\n%s", v)
|
|
}
|
|
tm, cmd = tm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
tm = run(tm, cmd)
|
|
d := tm.(consoleModel).menuD
|
|
if d == nil || len(d.items) != 2 {
|
|
t.Fatalf("files offered: %+v", d)
|
|
}
|
|
tm = press(tm, "down", "enter")
|
|
f := tm.(consoleModel).formD
|
|
if f == nil || f.fields[0].value() != "qwen2.5-3b-instruct" {
|
|
t.Fatalf("form: %+v", f)
|
|
}
|
|
f.focus = len(f.fields)
|
|
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
act := tm.(consoleModel).action
|
|
got := strings.Join(act.targets, " ")
|
|
if act.kind != "run" || !strings.Contains(got, "model serve bartowski/Qwen2.5-3B-Instruct-GGUF --file=Qwen2.5-3B-Instruct-Q8_0.gguf --name=qwen2.5-3b-instruct --replicas=1") {
|
|
t.Fatalf("would run %q %q", act.kind, got)
|
|
}
|
|
}
|
|
|
|
func TestTheManualHasEveryPageAndFindsInThem(t *testing.T) {
|
|
v := newDocViewer()
|
|
for _, k := range []string{"console", "install", "reference", "commands", "changes"} {
|
|
if findTopic(v.topics, k) < 0 {
|
|
t.Errorf("no %s page", k)
|
|
}
|
|
}
|
|
cmds := commandsManual()
|
|
for _, c := range []string{"llmgw ctl model update", "llmgw users approve", "llmgw docs"} {
|
|
if !strings.Contains(cmds, "# "+c) {
|
|
t.Errorf("the commands page lacks %s", c)
|
|
}
|
|
}
|
|
// Every line fits the width it was laid out for.
|
|
for _, tp := range v.topics {
|
|
for _, l := range renderMarkdown(tp.src(), 80) {
|
|
if !strings.HasPrefix(l.plain, " ") && len([]rune(l.plain)) > 80 {
|
|
t.Errorf("%s: a line of %d: %q", tp.key, len([]rune(l.plain)), l.plain)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
v.show(findTopic(v.topics, "console"))
|
|
v.query = "drain"
|
|
v.find(80, 0, 1)
|
|
if l := v.lines(80)[v.scroll+2]; !strings.Contains(strings.ToLower(l.plain), "drain") {
|
|
t.Errorf("found at %q", l.plain)
|
|
}
|
|
v.openTOC(80)
|
|
if v.toc == nil || len(v.toc.items) < 5 {
|
|
t.Fatal("no contents")
|
|
}
|
|
}
|
|
|
|
func TestQuestionMarkOpensTheConsolesPage(t *testing.T) {
|
|
m, _ := consoleWith(t, cNodes)
|
|
tm := press(m, "?")
|
|
cm := tm.(consoleModel)
|
|
if cm.menu != cManual || !cm.detail || cm.docs.topics[cm.docs.open].key != "console" {
|
|
t.Fatalf("menu %d detail %v", cm.menu, cm.detail)
|
|
}
|
|
if !strings.Contains(tm.View(), "The console") {
|
|
t.Error("the page is not shown")
|
|
}
|
|
}
|
|
|
|
func TestNextFindsTheNextMatchAndComesRound(t *testing.T) {
|
|
v := newDocViewer()
|
|
v.show(findTopic(v.topics, "console"))
|
|
v.query = "revok"
|
|
lines := v.lines(80)
|
|
var hits []int
|
|
for i, l := range lines {
|
|
if strings.Contains(strings.ToLower(l.plain), "revok") {
|
|
hits = append(hits, i)
|
|
}
|
|
}
|
|
if len(hits) < 2 {
|
|
t.Fatalf("hits %v", hits)
|
|
}
|
|
v.find(80, 0, 1)
|
|
seen := []int{v.scroll + 2}
|
|
for range hits {
|
|
v.key(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}, 80, 30)
|
|
seen = append(seen, v.scroll+2)
|
|
}
|
|
if seen[0] != hits[0] || seen[1] != hits[1] || seen[len(hits)] != hits[0] {
|
|
t.Errorf("hits %v, went %v", hits, seen)
|
|
}
|
|
}
|