The README's status stopped at v0.9 and listed gaps since closed; onboarding still said there was no SSO and no roles. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
672 lines
22 KiB
Go
672 lines
22 KiB
Go
package cli
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
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/control/modelserve"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||
)
|
||
|
||
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)
|
||
}
|
||
// The control plane's plan of the repository's files.
|
||
plan := modelserve.Plan{Repo: "bartowski/Qwen2.5-3B-Instruct-GGUF", Revision: "abc", Format: "gguf", Context: 16384,
|
||
Servable: true, Artifacts: []modelserve.PlannedArtifact{
|
||
{Artifact: modelplan.Artifact{Name: "Qwen2.5-3B-Instruct-Q4_K_M.gguf", Format: "gguf", Bytes: 2 << 30}},
|
||
{Artifact: modelplan.Artifact{Name: "Qwen2.5-3B-Instruct-Q8_0.gguf", Format: "gguf", Bytes: 3 << 30}},
|
||
{Artifact: modelplan.Artifact{Name: "Qwen2.5-3B-Instruct-F16.gguf", Format: "gguf", Bytes: 6 << 30}},
|
||
}}
|
||
gpu := modelplan.Option{Format: "gguf", Engine: "llamacpp", Class: "6000-24g", GPUsPerNode: 1, Nodes: 1,
|
||
NeedGB: 5, HaveGB: 21.6, Hosts: 1, FreeNow: true}
|
||
cpu := modelplan.Option{Format: "gguf", Engine: "llamacpp", CPU: true, NeedGB: 5, HaveGB: 27, Hosts: 2, FreeNow: true}
|
||
for _, o := range []struct {
|
||
art string
|
||
o modelplan.Option
|
||
}{{"Qwen2.5-3B-Instruct-Q8_0.gguf", gpu}, {"Qwen2.5-3B-Instruct-Q4_K_M.gguf", gpu},
|
||
{"Qwen2.5-3B-Instruct-Q8_0.gguf", cpu}, {"Qwen2.5-3B-Instruct-F16.gguf", cpu}} {
|
||
o.o.Artifact = o.art
|
||
plan.Options = append(plan.Options, o.o)
|
||
}
|
||
for i := range plan.Artifacts {
|
||
for j := range plan.Options {
|
||
if plan.Options[j].Artifact == plan.Artifacts[i].Name {
|
||
plan.Artifacts[i].Best = &plan.Options[j]
|
||
break
|
||
}
|
||
}
|
||
}
|
||
var started modelserve.Request
|
||
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path == "/api/v1/plan" && r.URL.Query().Get("repo") == plan.Repo {
|
||
_ = json.NewEncoder(w).Encode(plan)
|
||
return
|
||
}
|
||
if r.Method == http.MethodPost && r.URL.Path == "/api/v1/models/serve" {
|
||
_ = json.NewDecoder(r.Body).Decode(&started)
|
||
w.WriteHeader(http.StatusAccepted)
|
||
_ = json.NewEncoder(w).Encode(modelserve.Job{ID: "serve-1", State: modelserve.Running})
|
||
return
|
||
}
|
||
_, _ = w.Write([]byte("[]"))
|
||
}))
|
||
defer admin.Close()
|
||
m := newConsoleModel(admin.URL, 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) != 3 || !strings.Contains(d.items[0].label, "★") ||
|
||
!strings.Contains(d.items[0].label, "Q8_0") || !strings.Contains(d.items[0].label, "1 × 6000-24g") ||
|
||
!strings.Contains(d.items[2].label, "CPU") {
|
||
t.Fatalf("files offered: %+v", d)
|
||
}
|
||
tm = press(tm, "enter")
|
||
f := tm.(consoleModel).formD
|
||
if f == nil || f.fields[0].value() != "qwen2.5-3b-instruct" || len(f.fields[4].options) != 2 {
|
||
t.Fatalf("form: %+v", f)
|
||
}
|
||
f.focus = len(f.fields)
|
||
tm = pressRun(tm, "enter")
|
||
// Started on the control plane; the console stays.
|
||
if act := tm.(consoleModel).action; act.kind != "" {
|
||
t.Fatalf("the console left to run %v", act)
|
||
}
|
||
if started.Repo != plan.Repo || started.File != "Qwen2.5-3B-Instruct-Q8_0.gguf" || started.GPUClass != "6000-24g" ||
|
||
started.GPUsPerNode != 1 || !started.Fallbacks || started.Name != "qwen2.5-3b-instruct" {
|
||
t.Fatalf("started %+v", started)
|
||
}
|
||
if v := tm.View(); !strings.Contains(v, "serve-1") {
|
||
t.Errorf("the start was not shown:\n%s", v)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
func TestTabAndArrowsGoRoundAFormFromApplyWithoutFalling(t *testing.T) {
|
||
m, _ := consoleWith(t, cQuotas)
|
||
var tm tea.Model = press(m, "a")
|
||
n := len(tm.(consoleModel).formD.fields)
|
||
// Round the form twice each way, through Apply.
|
||
for range 2 * (n + 1) {
|
||
tm = press(tm, "tab")
|
||
}
|
||
for range 2 * (n + 1) {
|
||
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyUp})
|
||
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||
}
|
||
for range 2 * (n + 1) {
|
||
tm = press(tm, "down")
|
||
}
|
||
if f := tm.(consoleModel).formD; f == nil || f.focus < 0 || f.focus > n {
|
||
t.Fatalf("focus %+v", f)
|
||
}
|
||
}
|
||
|
||
func TestEveryScreenShowsTheVersionTopRight(t *testing.T) {
|
||
m, _ := consoleWith(t, cNodes)
|
||
first := strings.Split(m.View(), "\n")[0]
|
||
if !strings.Contains(first, version.Version) {
|
||
t.Errorf("the console's top line: %q", first)
|
||
}
|
||
d := docsModel{v: newDocViewer(), width: 100, height: 30}
|
||
d.v.show(0)
|
||
if first := strings.Split(d.View(), "\n")[0]; !strings.Contains(first, version.Version) {
|
||
t.Errorf("the manual's top line: %q", first)
|
||
}
|
||
}
|
||
|
||
func TestJobsAreFollowedFromTheirOwnView(t *testing.T) {
|
||
m, _ := consoleWith(t, cJobs)
|
||
m.width = 160
|
||
m.data.jobs = []modelserve.Job{{ID: "serve-9", Model: "phi-4", State: modelserve.Running, Started: time.Now(),
|
||
Steps: []modelserve.Step{{Name: "registry", State: modelserve.Done, Detail: "phi-4-Q8_0.gguf"},
|
||
{Name: "mirror", State: modelserve.Running, Detail: "fetching: 3.1 of 14.6 GB (21%)"}}}}
|
||
v := m.View()
|
||
if !strings.Contains(v, "Jobs (1)") || !strings.Contains(v, "fetching: 3.1 of 14.6 GB") {
|
||
t.Fatalf("%s", v)
|
||
}
|
||
tm := press(m, "enter")
|
||
if v := tm.View(); !strings.Contains(v, "serve-9") || !strings.Contains(v, "phi-4-Q8_0.gguf") {
|
||
t.Errorf("steps not shown:\n%s", v)
|
||
}
|
||
}
|
||
|
||
func TestTheMonitorDrawsFromPrometheusAndWithoutIt(t *testing.T) {
|
||
pts := func(vals ...float64) []promq.Series {
|
||
s := promq.Series{Name: "x"}
|
||
for _, v := range vals {
|
||
s.Points = append(s.Points, promq.Point{Value: v})
|
||
}
|
||
return []promq.Series{s}
|
||
}
|
||
var asked adminapi.MetricsRequest
|
||
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v1/metrics/range" {
|
||
_, _ = w.Write([]byte("[]"))
|
||
return
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&asked)
|
||
res := adminapi.MetricsResult{Configured: true, Series: map[string][]promq.Series{
|
||
"cpu": pts(10, 40, 80), "mem": pts(50, 55), "rps": pts(1, 2, 3), "rpsby": pts(1, 2, 3), "tokout": pts(100, 200),
|
||
"netin": pts(1e6, 2e6),
|
||
}}
|
||
res.Series["rpsby"][0].Name = "qwen"
|
||
_ = json.NewEncoder(w).Encode(res)
|
||
}))
|
||
defer admin.Close()
|
||
m := newConsoleModel(admin.URL, "/nonexistent/server.yaml")
|
||
m.loaded, m.menu, m.inPanel, m.width, m.height = true, cMonitor, true, 160, 60
|
||
var tm tea.Model = m
|
||
tm, cmd := tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||
tm, _ = tm.Update(cmd())
|
||
if asked.Window != time.Hour || len(asked.Queries) < 10 {
|
||
t.Fatalf("asked %+v", asked)
|
||
}
|
||
v := tm.View()
|
||
for _, want := range []string{"[1h]", "CPU", "80%", "Requests", "qwen", "from Prometheus", "Network", "1.9 MB/s"} {
|
||
if !strings.Contains(v, want) {
|
||
t.Errorf("no %q:\n%s", want, v)
|
||
}
|
||
}
|
||
if !strings.ContainsAny(v, "⣀⣤⣶⣿") {
|
||
t.Errorf("no braille chart:\n%s", v)
|
||
}
|
||
|
||
// No Prometheus: what the console saw, and it says so.
|
||
m2, _ := consoleWith(t, cMonitor)
|
||
m2.width, m2.height = 160, 60
|
||
for _, c := range []float64{20, 60} {
|
||
m2.data.nodes = []adminapi.NodeView{{ID: "n1", State: "ready", Load: &adminapi.LoadView{CPUPercent: c, MemPercent: 30,
|
||
MemUsed: 3, MemTotal: 10}}}
|
||
m2.mon.sample(m2.data)
|
||
}
|
||
if v := m2.View(); !strings.Contains(v, "since it opened") || !strings.Contains(v, "60%") {
|
||
t.Errorf("local:\n%s", v)
|
||
}
|
||
}
|
||
|
||
func TestBrailleChartsFillToTheirHeight(t *testing.T) {
|
||
rows := brailleChart([]chartSeries{{values: []float64{0, 50, 100, 100}}}, 2, 2, 100)
|
||
if len(rows) != 2 {
|
||
t.Fatal(rows)
|
||
}
|
||
// The right cell is full top to bottom; the left holds a low point and a middle one.
|
||
if !strings.Contains(rows[0], "⣿") || !strings.Contains(rows[1], "⣿") {
|
||
t.Errorf("%q", rows)
|
||
}
|
||
if s := sparkline([]float64{0, 1, 2, 3}, 4, 3); !strings.Contains(s, "▁") || !strings.Contains(s, "█") {
|
||
t.Errorf("%q", s)
|
||
}
|
||
}
|
||
|
||
func TestANodesPageHasItsCharts(t *testing.T) {
|
||
var asked adminapi.MetricsRequest
|
||
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v1/metrics/range" {
|
||
_, _ = w.Write([]byte("[]"))
|
||
return
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&asked)
|
||
_ = json.NewEncoder(w).Encode(adminapi.MetricsResult{Configured: true, Series: map[string][]promq.Series{
|
||
"cpu": {{Name: "n1", Points: []promq.Point{{Value: 12}, {Value: 34}}}}}})
|
||
}))
|
||
defer admin.Close()
|
||
m := newConsoleModel(admin.URL, "/nonexistent/server.yaml")
|
||
m.loaded, m.menu, m.inPanel, m.width, m.height = true, cNodes, true, 160, 60
|
||
m.data.nodes = []adminapi.NodeView{{ID: "n1", State: "ready"}}
|
||
var tm tea.Model = m
|
||
tm = press(tm, "enter") // the menu; Details first
|
||
tm, cmd := tm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||
if cmd == nil {
|
||
t.Fatal("opening the page read nothing")
|
||
}
|
||
tm, _ = tm.Update(cmd())
|
||
if len(asked.Queries) == 0 || !strings.Contains(asked.Queries[0].Expr, `node="n1"`) {
|
||
t.Fatalf("asked %+v", asked.Queries)
|
||
}
|
||
if v := tm.View(); !strings.Contains(v, "Over the last") || !strings.Contains(v, "34%") {
|
||
t.Errorf("%s", v)
|
||
}
|
||
}
|
||
|
||
func TestAWindowsSamplesFillTheChartsWidth(t *testing.T) {
|
||
// Five minutes at five-second steps is sixty samples; the chart is a
|
||
// hundred and twenty dots across. Every column is drawn.
|
||
to := time.Now()
|
||
s := chartSeries{from: to.Add(-5 * time.Minute), to: to}
|
||
for i := 0; i < 60; i++ {
|
||
s.at = append(s.at, to.Add(-5*time.Minute+time.Duration(i)*5*time.Second))
|
||
s.values = append(s.values, 50)
|
||
}
|
||
for c, v := range s.fit(120) {
|
||
if math.IsNaN(v) {
|
||
t.Fatalf("column %d of 120 empty", c)
|
||
}
|
||
}
|
||
// A series that began two minutes ago fills the last two fifths only.
|
||
s.at, s.values = s.at[36:], s.values[36:]
|
||
got := s.fit(100)
|
||
if !math.IsNaN(got[50]) || math.IsNaN(got[70]) || math.IsNaN(got[99]) {
|
||
t.Errorf("a late start: %v", got)
|
||
}
|
||
}
|
||
|
||
func TestGuardrailsSayWhereTheyApplyFrom(t *testing.T) {
|
||
var m consoleModel
|
||
m.people = peopleData{
|
||
orgs: []orgRow{
|
||
{level: "org", id: "o1", path: "Acme"},
|
||
{level: "team", id: "t1", path: "Acme/ml"},
|
||
{level: "project", id: "p1", path: "Acme/ml/train"},
|
||
},
|
||
guardPolicies: []string{"base", "finance", "strict"},
|
||
guardDefault: []string{"base"},
|
||
guardAt: []guard.Attachment{
|
||
{Scope: "team", ScopeID: "t1", Policy: "finance"},
|
||
{Scope: "project", ScopeID: "p1", Policy: "strict"},
|
||
},
|
||
}
|
||
labels := []string{}
|
||
for _, it := range m.guardMenu(m.people.orgs[2]).items {
|
||
labels = append(labels, it.label)
|
||
}
|
||
want := []string{"applies from everywhere", "applies from Acme/ml", "✓ strict"}
|
||
for i, w := range want {
|
||
if !strings.Contains(labels[i], w) {
|
||
t.Errorf("%q lacks %q", labels[i], w)
|
||
}
|
||
}
|
||
if c := m.guardCell(m.people.orgs[1]); c != "finance" {
|
||
t.Errorf("team cell %q", c)
|
||
}
|
||
if items := m.orgMenu(m.people.orgs[0]).items; !slices.ContainsFunc(items, func(i menuItem) bool { return i.label == "Guardrails…" }) {
|
||
t.Error("no Guardrails… in the menu")
|
||
}
|
||
if items := m.orgMenu(m.people.orgs[0]).items; !slices.ContainsFunc(items, func(i menuItem) bool { return i.label == "Longest key lifetime…" }) {
|
||
t.Error("no Longest key lifetime… in the menu")
|
||
}
|
||
if items := m.keyMenu(store.KeyRow{}).items; !slices.ContainsFunc(items, func(i menuItem) bool { return i.label == "Rotate…" }) {
|
||
t.Error("no Rotate… on a key")
|
||
}
|
||
}
|
||
|
||
func TestTheAuditViewListsChangesAndOpensOne(t *testing.T) {
|
||
m, _ := consoleWith(t, cAudit)
|
||
m.width, m.height = 160, 40
|
||
m.data.audit = []store.AuditEvent{{At: time.Now(), Actor: "ilker", Source: "api",
|
||
Action: "POST /api/v1/models", Target: "qwen", Status: 200, Detail: `{"name":"qwen"}`}}
|
||
if v := m.View(); !strings.Contains(v, "POST /api/v1/models") || !strings.Contains(v, "ilker") {
|
||
t.Fatalf("not listed:\n%s", v)
|
||
}
|
||
tm := press(m, "enter")
|
||
if v := tm.View(); !strings.Contains(v, `"name":"qwen"`) {
|
||
t.Errorf("not opened:\n%s", v)
|
||
}
|
||
}
|