Files
llmgw/internal/cli/tui_actions_test.go
T
2026-10-04 20:33:59 +02:00

699 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/catalog"
"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)
}
}
func TestAModelsMenuOffersACanaryAndThenRunsIt(t *testing.T) {
m, _ := consoleWith(t, cModels)
labels := func(md adminapi.ModelView) []string {
var out []string
for _, it := range m.canaryItems(md) {
out = append(out, it.label)
}
return out
}
md := adminapi.ModelView{Name: "qwen", Update: &modelserve.UpdateInfo{Available: true, Latest: "def98765"}}
if got := labels(md); len(got) != 1 || !strings.Contains(got[0], "canary") {
t.Errorf("with new weights: %v", got)
}
md.Canary = &catalog.Canary{Model: "qwen-canary", Percent: 10}
got := strings.Join(labels(md), " | ")
for _, want := range []string{"share (10%)", "Promote", "Take the canary out"} {
if !strings.Contains(got, want) {
t.Errorf("with a canary, %q lacks %q", got, want)
}
}
if got := labels(adminapi.ModelView{Name: "qwen-canary", CanaryOf: "qwen",
Update: &modelserve.UpdateInfo{Available: true}}); len(got) != 0 {
t.Errorf("a canary offered a canary of its own: %v", got)
}
}