diff --git a/.gitignore b/.gitignore index 3f5287bc8..a4c84a0e8 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,8 @@ formal-verification/out/ # package directory itself and untrack the source. /apexentries /.github/ci/apexentries/apexentries + +# Runtime state written by `local-ai run` when it is started from the repo +# root, which is what a contributor testing a build does. Nothing under here is +# source: it is the instance's own models, outputs, traces and identity. +/data/ diff --git a/core/application/startup.go b/core/application/startup.go index 26b2f5b78..b46af7046 100644 --- a/core/application/startup.go +++ b/core/application/startup.go @@ -444,6 +444,13 @@ func New(opts ...config.AppOption) (*Application, error) { // when gallery data refreshes instead of using a fixed TTL. vram.SetGalleryGenerationFunc(gallery.GalleryGeneration) + // Fill those caches ahead of the first visitor. An estimate for an entry + // nobody has asked about yet costs a remote probe of its weight files, and + // the model gallery asks for one per row, so without this the first page + // spends seconds filling in its own sizes while somebody watches it. + // Non-blocking, and bounded: see DefaultEstimateWarmConfig. + gallery.WarmEstimateCache(options.Context, options.Galleries, options.SystemState, gallery.EstimateWarmConfigFromEnv()) + if options.ConfigFile != "" { if err := application.ModelConfigLoader().LoadMultipleModelConfigsSingleFile(options.ConfigFile, configLoaderOpts...); err != nil { xlog.Error("error loading config file", "error", err) diff --git a/core/gallery/estimate_warm.go b/core/gallery/estimate_warm.go new file mode 100644 index 000000000..8ed9e42e8 --- /dev/null +++ b/core/gallery/estimate_warm.go @@ -0,0 +1,188 @@ +package gallery + +import ( + "context" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/system" + "github.com/mudler/LocalAI/pkg/vram" + "github.com/mudler/xlog" +) + +// EstimateInput builds the VRAM estimator's input from a gallery entry. +// +// It lives here rather than beside the HTTP handler because two callers need +// it: the handler answering one model, and the warmer below answering all of +// them ahead of time. +func EstimateInput(m *GalleryModel) vram.ModelEstimateInput { + var input vram.ModelEstimateInput + input.Size = m.Size + if repoID := extractHFRepo(m.Overrides, m.URLs); repoID != "" { + input.HFRepo = repoID + } + for _, f := range m.AdditionalFiles { + if vram.IsWeightFile(f.URI) { + input.Files = append(input.Files, vram.FileInput{URI: f.URI, Size: 0}) + } + } + return input +} + +// extractHFRepo finds a HuggingFace repo ID in a model's overrides or URLs. +func extractHFRepo(overrides map[string]any, urls []string) string { + if overrides != nil { + if params, ok := overrides["parameters"].(map[string]any); ok { + if modelRef, ok := params["model"].(string); ok { + if repoID, ok := vram.ExtractHFRepoID(modelRef); ok { + return repoID + } + } + } + } + for _, u := range urls { + if repoID, ok := vram.ExtractHFRepoID(u); ok { + return repoID + } + } + return "" +} + +// EstimateWarmConfig bounds the background warm-up. +type EstimateWarmConfig struct { + // Limit is how many gallery entries to warm, in gallery order. Zero + // disables warming entirely. The order matters: it is the order the UI + // lists them in, so the entries a user sees first are warmed first. + Limit int + // Concurrency is how many estimates run at once. Each one can be a remote + // probe, so this is deliberately small: the point is to be finished before + // anybody looks, not to saturate the link or the upstream. + Concurrency int + // Contexts are the context lengths to estimate at. These want to match what + // the UI asks for, or the warmed entry is not the one it reads. + Contexts []uint32 +} + +// DefaultEstimateWarmConfig is what the server uses unless told otherwise. +// +// The limit is a deliberate compromise. Warming the whole gallery would be +// thousands of remote probes on every boot, which is rude to the upstream and +// slow to finish; warming nothing leaves the first page of the model gallery +// paying two seconds per row. A few hundred covers what anyone browses in a +// sitting, and everything past it still warms itself on first view. +var DefaultEstimateWarmConfig = EstimateWarmConfig{ + Limit: 300, + Concurrency: 4, + Contexts: []uint32{8192, 16384, 32768, 65536, 131072, 262144}, +} + +// WarmEstimateCache fills the VRAM estimate caches in the background. +// +// An estimate for an entry the server has never seen costs a network probe of +// its weight files, seconds of it, and the UI asks for one per row. Doing that +// work at startup rather than on the first click is the difference between a +// gallery that reads instantly and one that spends ten seconds filling in its +// own sizes while somebody watches. +// +// It returns immediately; the work happens on its own goroutine and stops when +// ctx is done. Failures are logged at debug and otherwise ignored: a warm-up +// that cannot reach an upstream must never stop the server from starting, and +// the entry it failed on simply stays cold. +func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, cfg EstimateWarmConfig) { + if cfg.Limit <= 0 || cfg.Concurrency <= 0 { + return + } + + go func() { + started := time.Now() + + models, err := AvailableGalleryModelsCached(galleries, systemState) + if err != nil { + xlog.Debug("VRAM estimate warm-up skipped, gallery unavailable", "error", err) + return + } + if len(models) > cfg.Limit { + models = models[:cfg.Limit] + } + if len(models) == 0 { + return + } + + var ( + wg sync.WaitGroup + cursor = make(chan *GalleryModel) + warmed int + mu sync.Mutex + ) + + for i := 0; i < cfg.Concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for m := range cursor { + input := EstimateInput(m) + if len(input.Files) == 0 && input.HFRepo == "" && input.Size == "" { + continue + } + // Per entry, not for the run: one unreachable weight file + // must not hold a worker for the whole warm-up. + entryCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + _, err := vram.EstimateModelMultiContext(entryCtx, input, cfg.Contexts) + cancel() + if err != nil { + xlog.Debug("VRAM estimate warm-up failed for entry", "model", m.GetName(), "error", err) + continue + } + mu.Lock() + warmed++ + mu.Unlock() + } + }() + } + + feed: + for _, m := range models { + select { + case <-ctx.Done(): + break feed + case cursor <- m: + } + } + close(cursor) + wg.Wait() + + if ctx.Err() != nil { + xlog.Debug("VRAM estimate warm-up stopped", "warmed", warmed) + return + } + xlog.Info("VRAM estimate cache warmed", "entries", warmed, "of", len(models), "took", time.Since(started).Round(time.Second)) + }() +} + +// EstimateWarmConfigFromEnv reads the warm-up bounds from the environment, +// falling back to the defaults. +// +// LOCALAI_VRAM_WARM_LIMIT entries to warm; 0 disables the warm-up +// LOCALAI_VRAM_WARM_CONCURRENCY estimates in flight at once +// +// Env rather than a flag because it is an operational tuning knob, not part of +// what the server does: an air-gapped host wants it off, and a host behind a +// slow link wants it slower, and neither is a decision the CLI should carry. +func EstimateWarmConfigFromEnv() EstimateWarmConfig { + cfg := DefaultEstimateWarmConfig + if v, ok := os.LookupEnv("LOCALAI_VRAM_WARM_LIMIT"); ok { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 { + cfg.Limit = n + } + } + if v, ok := os.LookupEnv("LOCALAI_VRAM_WARM_CONCURRENCY"); ok { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { + cfg.Concurrency = n + } + } + return cfg +} diff --git a/core/gallery/estimate_warm_test.go b/core/gallery/estimate_warm_test.go new file mode 100644 index 000000000..8d12b4701 --- /dev/null +++ b/core/gallery/estimate_warm_test.go @@ -0,0 +1,106 @@ +package gallery_test + +import ( + "context" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/pkg/system" +) + +var _ = Describe("VRAM estimate warm-up", func() { + var state *system.SystemState + + BeforeEach(func() { + dir, err := os.MkdirTemp("", "warm") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(dir) }) + state, err = system.GetSystemState(system.WithModelPath(dir)) + Expect(err).ToNot(HaveOccurred()) + gallery.ResetGalleryModelCache() + DeferCleanup(gallery.ResetGalleryModelCache) + }) + + It("does nothing when disabled, and returns without blocking", func() { + cfg := gallery.DefaultEstimateWarmConfig + cfg.Limit = 0 + + done := make(chan struct{}) + go func() { + defer close(done) + gallery.WarmEstimateCache(context.Background(), []config.Gallery{}, state, cfg) + }() + Eventually(done, "1s").Should(BeClosed()) + }) + + It("returns immediately even when there is work to do", func() { + // The caller is a server still starting up: warming must never be on + // the path to listening. + done := make(chan struct{}) + go func() { + defer close(done) + gallery.WarmEstimateCache(context.Background(), []config.Gallery{}, state, gallery.DefaultEstimateWarmConfig) + }() + Eventually(done, "1s").Should(BeClosed()) + }) + + It("stops when its context is cancelled", func() { + ctx, cancel := context.WithCancel(context.Background()) + gallery.WarmEstimateCache(ctx, []config.Gallery{}, state, gallery.DefaultEstimateWarmConfig) + cancel() + // Nothing to assert beyond not hanging or panicking: an aborted warm-up + // leaves entries cold, which is the state they were already in. + Consistently(func() bool { return true }, "100ms").Should(BeTrue()) + }) + + Describe("configuration from the environment", func() { + AfterEach(func() { + os.Unsetenv("LOCALAI_VRAM_WARM_LIMIT") + os.Unsetenv("LOCALAI_VRAM_WARM_CONCURRENCY") + }) + + It("falls back to the defaults", func() { + cfg := gallery.EstimateWarmConfigFromEnv() + Expect(cfg.Limit).To(Equal(gallery.DefaultEstimateWarmConfig.Limit)) + Expect(cfg.Concurrency).To(Equal(gallery.DefaultEstimateWarmConfig.Concurrency)) + }) + + It("lets an operator turn it off entirely", func() { + os.Setenv("LOCALAI_VRAM_WARM_LIMIT", "0") + Expect(gallery.EstimateWarmConfigFromEnv().Limit).To(BeZero()) + }) + + It("lets an operator slow it down", func() { + os.Setenv("LOCALAI_VRAM_WARM_CONCURRENCY", "1") + Expect(gallery.EstimateWarmConfigFromEnv().Concurrency).To(Equal(1)) + }) + + It("ignores values that are not usable", func() { + os.Setenv("LOCALAI_VRAM_WARM_LIMIT", "not-a-number") + os.Setenv("LOCALAI_VRAM_WARM_CONCURRENCY", "0") + cfg := gallery.EstimateWarmConfigFromEnv() + Expect(cfg.Limit).To(Equal(gallery.DefaultEstimateWarmConfig.Limit)) + // Zero workers would be a warm-up that never runs while looking + // enabled, so it keeps the default rather than honouring it. + Expect(cfg.Concurrency).To(Equal(gallery.DefaultEstimateWarmConfig.Concurrency)) + }) + }) + + It("keeps the estimate contexts the UI actually asks for", func() { + // A warmed entry at the wrong context lengths is a cache the gallery + // never reads, so this pins them together. + Expect(gallery.DefaultEstimateWarmConfig.Contexts).To(ContainElements( + uint32(8192), uint32(16384), uint32(32768), uint32(65536), uint32(131072), uint32(262144), + )) + }) + + It("bounds concurrency so a warm-up cannot saturate the link", func() { + Expect(gallery.DefaultEstimateWarmConfig.Concurrency).To(BeNumerically("<=", 8)) + Expect(gallery.DefaultEstimateWarmConfig.Concurrency).To(BeNumerically(">", 0)) + }) + +}) diff --git a/core/gallery/gallery.go b/core/gallery/gallery.go index 12a038577..53d623a02 100644 --- a/core/gallery/gallery.go +++ b/core/gallery/gallery.go @@ -325,10 +325,32 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst var ( availableModelsMu sync.RWMutex availableModelsCache GalleryElements[*GalleryModel] - refreshing atomic.Bool - galleryGeneration atomic.Uint64 + // Whether a load has happened, tracked apart from the slice itself. A + // gallery that legitimately holds nothing caches as an empty (often nil) + // slice, and testing the slice for nil read that as "never loaded": every + // call then took the blocking path and bumped the generation, which is the + // same cache-defeating loop the refresh interval exists to stop. + availableModelsLoaded bool + refreshing atomic.Bool + galleryGeneration atomic.Uint64 + lastRefreshUnixNano atomic.Int64 ) +// How often the cached model list may be refreshed from upstream. +// +// This is a floor on refresh frequency, not a TTL: the cache is served +// regardless, and this only decides how often a background re-fetch is worth +// starting. It matters far more than it looks, because a refresh bumps +// galleryGeneration, and that invalidates every VRAM estimate cache in +// pkg/vram. Refreshing on every call therefore kept those caches permanently +// cold: the gallery listing is one request but the UI asks for one VRAM +// estimate per row, so a single page view triggered dozens of refreshes and +// every estimate paid full price for a remote probe it had already made. +// +// A package variable rather than a constant so tests can drive refreshes +// without waiting. +var GalleryRefreshInterval = 5 * time.Minute + // GalleryGeneration returns a counter that increments each time the gallery // model list is refreshed from upstream. VRAM estimation caches use this to // invalidate entries when the gallery data changes. @@ -352,7 +374,11 @@ func ResetGalleryModelCache() { } availableModelsMu.Lock() availableModelsCache = nil + availableModelsLoaded = false availableModelsMu.Unlock() + // Also clear the refresh stamp, or a suite that reset the cache would find + // the next refresh throttled by the previous spec's clock. + lastRefreshUnixNano.Store(0) } // AvailableGalleryModelsCached returns gallery models from an in-memory cache. @@ -363,9 +389,10 @@ func ResetGalleryModelCache() { func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryModel], error) { availableModelsMu.RLock() cached := availableModelsCache + loaded := availableModelsLoaded availableModelsMu.RUnlock() - if cached != nil { + if loaded { // Refresh installed status under write lock to avoid races with // concurrent readers and the background refresh goroutine. availableModelsMu.Lock() @@ -387,8 +414,10 @@ func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *syste availableModelsMu.Lock() availableModelsCache = models + availableModelsLoaded = true galleryGeneration.Add(1) availableModelsMu.Unlock() + lastRefreshUnixNano.Store(time.Now().UnixNano()) return models, nil } @@ -397,9 +426,18 @@ func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *syste // gallery model cache. Only one refresh runs at a time; concurrent calls // are no-ops. func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.SystemState) { + if GalleryRefreshInterval > 0 { + last := lastRefreshUnixNano.Load() + if last != 0 && time.Since(time.Unix(0, last)) < GalleryRefreshInterval { + return + } + } if !refreshing.CompareAndSwap(false, true) { return } + // Stamped before the fetch rather than after, so a slow upstream cannot + // let a queue of callers each start their own refresh behind this one. + lastRefreshUnixNano.Store(time.Now().UnixNano()) go func() { defer refreshing.Store(false) models, err := AvailableGalleryModels(galleries, systemState) @@ -408,12 +446,37 @@ func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.Syste return } availableModelsMu.Lock() + changed := !sameModelSet(availableModelsCache, models) availableModelsCache = models - galleryGeneration.Add(1) + availableModelsLoaded = true + // Only a real change invalidates the VRAM caches. An unchanged gallery + // re-fetched on schedule must not throw away work that is still valid, + // which is the difference between an estimate costing nothing and + // costing a network round trip. + if changed { + galleryGeneration.Add(1) + } availableModelsMu.Unlock() }() } +// sameModelSet reports whether two model lists describe the same gallery, for +// the purpose of deciding whether derived caches are still valid. Names and +// order are enough: a change to an entry's files or size arrives with a new +// gallery index, and comparing every field on every entry would cost more than +// the caches save. +func sameModelSet(a, b GalleryElements[*GalleryModel]) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].GetName() != b[i].GetName() { + return false + } + } + return true +} + // List available backends func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) { return availableBackendsWithFilter(galleries, systemState, func(backend *GalleryBackend) bool { diff --git a/core/gallery/gallery_refresh_throttle_test.go b/core/gallery/gallery_refresh_throttle_test.go new file mode 100644 index 000000000..a1e9e4908 --- /dev/null +++ b/core/gallery/gallery_refresh_throttle_test.go @@ -0,0 +1,80 @@ +package gallery_test + +import ( + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/pkg/system" +) + +// The gallery generation counter is what every VRAM estimate cache keys on, so +// how often it moves decides whether those caches are worth having. Refreshing +// on every call kept them permanently cold: one page of the model gallery asks +// for a VRAM estimate per row, and each of those requests re-read the gallery, +// triggering a refresh that invalidated the estimate the previous row had just +// paid a network round trip for. +var _ = Describe("Gallery refresh throttling", func() { + var ( + tmp *system.SystemState + galleries []config.Gallery + origInterval time.Duration + ) + + BeforeEach(func() { + dir, err := os.MkdirTemp("", "gallery-throttle") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(dir) }) + + tmp, err = system.GetSystemState(system.WithModelPath(dir)) + Expect(err).ToNot(HaveOccurred()) + + // No upstream: the list comes back empty, which is all this needs. What + // is under test is how often a refresh is started, not what it returns. + galleries = []config.Gallery{} + origInterval = gallery.GalleryRefreshInterval + gallery.ResetGalleryModelCache() + }) + + AfterEach(func() { + gallery.GalleryRefreshInterval = origInterval + gallery.ResetGalleryModelCache() + }) + + It("does not bump the generation once per call", func() { + gallery.GalleryRefreshInterval = time.Hour + + _, err := gallery.AvailableGalleryModelsCached(galleries, tmp) + Expect(err).ToNot(HaveOccurred()) + start := gallery.GalleryGeneration() + + // Stands in for one page view: many callers in quick succession. + for i := 0; i < 30; i++ { + _, err := gallery.AvailableGalleryModelsCached(galleries, tmp) + Expect(err).ToNot(HaveOccurred()) + } + // Let any refresh that did start finish, so this cannot pass by racing. + Eventually(func() uint64 { return gallery.GalleryGeneration() }, "2s", "50ms"). + Should(Equal(start)) + }) + + It("still refreshes once the interval has passed", func() { + gallery.GalleryRefreshInterval = time.Millisecond + + _, err := gallery.AvailableGalleryModelsCached(galleries, tmp) + Expect(err).ToNot(HaveOccurred()) + + time.Sleep(5 * time.Millisecond) + _, err = gallery.AvailableGalleryModelsCached(galleries, tmp) + Expect(err).ToNot(HaveOccurred()) + + // An empty gallery refreshing to an empty gallery is unchanged, so the + // generation must hold: only a real change may invalidate the caches. + Consistently(func() uint64 { return gallery.GalleryGeneration() }, "300ms", "50ms"). + Should(Equal(gallery.GalleryGeneration())) + }) +}) diff --git a/core/http/react-ui/e2e/alias-template.spec.js b/core/http/react-ui/e2e/alias-template.spec.js index f3b1a0ca0..e9b13ba61 100644 --- a/core/http/react-ui/e2e/alias-template.spec.js +++ b/core/http/react-ui/e2e/alias-template.spec.js @@ -69,9 +69,9 @@ test.describe('Manage - alias badge', () => { test('renders a read-only alias -> target badge on aliased rows', async ({ page }) => { await page.goto('/app/manage') - await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 }) - - // The aliased row shows the target; the plain model row does not. + // The badge moved off the row and into the pane: it is a fact about the + // model, and the rail line is spent on state. + await page.locator('[data-entity="gpt-4"]').click() await expect(page.getByText('alias -> fast-llm')).toBeVisible({ timeout: 10_000 }) }) }) diff --git a/core/http/react-ui/e2e/backends-management.spec.js b/core/http/react-ui/e2e/backends-management.spec.js index 11b179189..34408c92b 100644 --- a/core/http/react-ui/e2e/backends-management.spec.js +++ b/core/http/react-ui/e2e/backends-management.spec.js @@ -1,6 +1,9 @@ import { test, expect } from './coverage-fixtures.js' // Backends admin page (src/pages/Backends.jsx). +const PANE = '[data-testid="backends-pane"]' +const railItem = (page, name) => page.locator(`[data-entity="${name}"]`) + test.describe('Backends management page', () => { test.beforeEach(async ({ page }) => { await page.goto('/app/backends') @@ -49,11 +52,14 @@ test.describe('Backends management page - Markdown descriptions', () => { }) }) await page.goto('/app/backends') - await expect(page.locator('th', { hasText: 'Description' })).toBeVisible({ timeout: 10_000 }) + // Rendered means the rail has entries. The old gate waited on a column + // header, and there are no columns now. + await expect(railItem(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 }) }) - test('table cell shows the description as clean text, not raw Markdown', async ({ page }) => { - const cell = page.locator('tr', { hasText: 'markdown-backend' }).locator('span[title]', { hasText: 'InsightFace' }) + test('the pane lede shows the description as clean text, not raw Markdown', async ({ page }) => { + await railItem(page, 'markdown-backend').click() + const cell = page.locator('.detail-pane__lede') await expect(cell).toHaveText(STRIPPED_DESCRIPTION) // The syntax itself must be gone, not merely rendered somewhere. @@ -65,15 +71,77 @@ test.describe('Backends management page - Markdown descriptions', () => { await expect(cell.locator('h1')).toHaveCount(0) }) - test('title tooltip carries the stripped text, not raw Markdown', async ({ page }) => { - const cell = page.locator('tr', { hasText: 'markdown-backend' }).locator('span[title]', { hasText: 'InsightFace' }) - - await expect(cell).toHaveAttribute('title', STRIPPED_DESCRIPTION) + test("the lede's tooltip carries the stripped text, not raw Markdown", async ({ page }) => { + await railItem(page, 'markdown-backend').click() + await expect(page.locator('.detail-pane__lede')).toHaveAttribute('title', STRIPPED_DESCRIPTION) }) - test('a backend with no description still shows the placeholder', async ({ page }) => { - const row = page.locator('tr', { hasText: 'plain-backend' }) - - await expect(row.locator('span[title=""]')).toHaveText('-') + test('a backend with no description renders no lede rather than a blank one', async ({ page }) => { + // The table needed a placeholder because an empty cell in a grid of full + // ones reads as a fault. The pane has no grid to keep aligned, so it omits + // the line - but must never print "undefined". + await railItem(page, 'plain-backend').click() + await expect(page.locator(PANE)).toContainText('plain-backend') + await expect(page.locator('.detail-pane__lede')).toHaveCount(0) + await expect(page.locator(PANE)).not.toContainText('undefined') + }) +}) + +test.describe('Backends gallery - split view', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/api/backends*', (route) => { + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + backends: [ + { name: 'llama-cpp', description: 'GGUF inference', installed: true, version: '1.52.0', license: 'MIT', tags: ['chat'] }, + { name: 'whisper', description: 'Speech to text', installed: true, version: '1.8.2', license: 'MIT', tags: ['transcript'] }, + { name: 'diffusers', description: 'Image generation', installed: false, license: 'Apache-2.0', tags: ['image'] }, + ], + }), + }) + }) + await page.goto('/app/backends') + await expect(railItem(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 }) + }) + + test('the gallery renders no table', async ({ page }) => { + await expect(page.locator('[data-testid="backends"]')).toBeVisible() + await expect(page.locator('table thead th')).toHaveCount(0) + }) + + test('with nothing selected the pane describes the host', async ({ page }) => { + await expect(page.locator(PANE)).toContainText('This host') + await expect(page.locator('[data-testid="backends-back"]')).toHaveCount(0) + }) + + test('choosing a backend turns the pane into its detail, and back returns', async ({ page }) => { + await railItem(page, 'llama-cpp').click() + await expect(page.locator(PANE)).toContainText('llama-cpp') + await expect(page.locator(PANE)).toContainText('MIT') + await expect(page.locator(PANE)).not.toContainText('This host') + + await page.locator('[data-testid="backends-back"]').click() + await expect(page.locator(PANE)).toContainText('This host') + }) + + test('the selection lives in the URL and survives a reload', async ({ page }) => { + await railItem(page, 'whisper').click() + await expect(page).toHaveURL(/[?&]backend=whisper/) + await page.reload() + await expect(railItem(page, 'whisper')).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('[data-testid="backends-back"]')).toBeVisible() + }) + + + test('the rail groups while browsing and flattens on a query', async ({ page }) => { + await expect(page.locator('[data-testid^="backends-rail-group-"]').first()).toBeVisible() + await page.locator('input[placeholder*="Search backends"]').fill('llama') + await expect(page.locator('[data-testid^="backends-rail-group-"]')).toHaveCount(0) + }) + + test('an installed backend states its version, an absent one says so', async ({ page }) => { + await expect(railItem(page, 'llama-cpp')).toContainText('v1.52.0') + await expect(railItem(page, 'diffusers')).toContainText('not installed') }) }) diff --git a/core/http/react-ui/e2e/discover-height.spec.js b/core/http/react-ui/e2e/discover-height.spec.js new file mode 100644 index 000000000..7ad7ba501 --- /dev/null +++ b/core/http/react-ui/e2e/discover-height.spec.js @@ -0,0 +1,72 @@ +import { test, expect } from './coverage-fixtures.js' + +// The split view is meant to scroll inside itself. It is easy to regress into +// scrolling the document instead, because the shell's height rules are floors +// (min-height: 100dvh) rather than ceilings, so any tall pane silently grows +// the whole column and takes the rail with it. +// A description long enough that the detail pane must overflow, which is the +// only condition under which the bug shows. +const LONG = Array.from({ length: 60 }, (_, i) => + `Paragraph ${i + 1}. This entry carries a long description so the detail pane has more content than the viewport can hold.`, +).join('\n\n') + +const MOCK = { + models: [ + { name: 'long-model', description: LONG, backend: 'llama-cpp', installed: false, tags: ['llm'] }, + { name: 'short-model', description: 'Short.', backend: 'llama-cpp', installed: false, tags: ['llm'] }, + ], + allBackends: ['llama-cpp'], allTags: ['llm'], + availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1, +} + +test.describe('Discover - the view scrolls, not the page', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/api/models*', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) })) + }) + + test('a long detail scrolls the pane and leaves the page height alone', async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }) + await page.goto('/app/models') + await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 }) + + const pageHeight = () => page.evaluate(() => document.documentElement.scrollHeight) + const railHeight = () => page.evaluate( + () => document.querySelector('.entity-rail')?.getBoundingClientRect().height, + ) + + const beforePage = await pageHeight() + const beforeRail = await railHeight() + + await page.locator('[data-testid="discover-rail-item"]').first().click() + await expect(page.locator('[data-testid="discover-back"]')).toBeVisible() + + // Selecting something must not make the document taller, and must not + // stretch the rail to match the pane. + expect(await pageHeight()).toBe(beforePage) + // Sub-pixel: layout can settle a fraction differently without the rail + // having grown. A pixel of tolerance keeps this about the bug it guards. + expect(Math.abs((await railHeight()) - beforeRail)).toBeLessThan(1) + + // The pane is the thing that scrolls. + const paneOverflows = await page.evaluate(() => { + const el = document.querySelector('.split-view__pane') + return el ? getComputedStyle(el).overflowY : null + }) + expect(paneOverflows).toBe('auto') + }) + + test('stacked below the breakpoint it scrolls with the document again', async ({ page }) => { + // Pinning the height when the columns stack would trap both halves in short + // scrollers, so the constraint is lifted there on purpose. + await page.setViewportSize({ width: 700, height: 800 }) + await page.goto('/app/models') + await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 }) + + const overflow = await page.evaluate(() => { + const el = document.querySelector('.split-view__pane') + return el ? getComputedStyle(el).overflowY : null + }) + expect(overflow).toBe('visible') + }) +}) diff --git a/core/http/react-ui/e2e/discover-search-focus.spec.js b/core/http/react-ui/e2e/discover-search-focus.spec.js new file mode 100644 index 000000000..d0c60425f --- /dev/null +++ b/core/http/react-ui/e2e/discover-search-focus.spec.js @@ -0,0 +1,52 @@ +import { test, expect } from './coverage-fixtures.js' + +// Searching triggers a refetch. The search box lives in the rail column, so if +// a refetch unmounts the view it takes the field you are typing into with it, +// dropping focus and the caret. That is what this guards. +const MOCK = { + models: [ + { name: 'alpha-model', description: 'a', backend: 'llama-cpp', installed: false, tags: ['llm'] }, + { name: 'beta-model', description: 'b', backend: 'llama-cpp', installed: false, tags: ['llm'] }, + ], + allBackends: ['llama-cpp'], allTags: ['llm'], + availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1, +} + +test.describe('Discover - searching keeps the view', () => { + test('a refetch keeps the search box, its focus and its value', async ({ page }) => { + let calls = 0 + await page.route('**/api/models*', async (route) => { + calls += 1 + // Slow the refetch so the loading window is real and observable. + if (calls > 1) await new Promise((r) => setTimeout(r, 600)) + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) }) + }) + + await page.goto('/app/models') + const search = page.locator('.filter-bar-group__search input') + await expect(search).toBeVisible({ timeout: 10_000 }) + + await search.click() + await search.fill('alpha') + + // Mid-refetch: the field is still mounted, still focused, still holding + // what was typed, and the rail is marked busy rather than replaced. + await expect(search).toBeFocused() + await expect(search).toHaveValue('alpha') + await expect(page.locator('.entity-rail')).toBeVisible() + + await page.waitForTimeout(900) + await expect(search).toBeFocused() + await expect(search).toHaveValue('alpha') + }) + + test('the first load still shows a skeleton, not an empty shell', async ({ page }) => { + // Nothing to keep on a cold start, so the skeleton is still right there. + await page.route('**/api/models*', async (route) => { + await new Promise((r) => setTimeout(r, 800)) + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) }) + }) + await page.goto('/app/models') + await expect(page.getByTestId('gallery-loader')).toBeVisible({ timeout: 5_000 }) + }) +}) diff --git a/core/http/react-ui/e2e/host-split-view.spec.js b/core/http/react-ui/e2e/host-split-view.spec.js new file mode 100644 index 000000000..e9875bb93 --- /dev/null +++ b/core/http/react-ui/e2e/host-split-view.spec.js @@ -0,0 +1,69 @@ +import { test, expect } from './coverage-fixtures.js' + +// Host is an inventory, not a catalog, so its split view differs from the two +// galleries in exactly one place: the pane with nothing selected reports what +// is happening rather than offering something to install. + +const PANE = '[data-testid="host-pane"]' +const railItems = (page) => page.locator('[data-testid="host-rail-item"]') +const railItem = (page, id) => page.locator(`[data-entity="${id}"]`) + +test.describe('Host - split view', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/app/manage') + await expect(railItems(page).first()).toBeVisible({ timeout: 10_000 }) + }) + + test('the inventory renders no table', async ({ page }) => { + await expect(page.locator('[data-testid="host"]')).toBeVisible() + await expect(page.locator('table thead th')).toHaveCount(0) + }) + + test('with nothing selected the pane reports the current state', async ({ page }) => { + await expect(page.locator(PANE)).toContainText('Right now') + await expect(page.locator(PANE)).toContainText('Loaded') + await expect(page.locator('[data-testid="host-back"]')).toHaveCount(0) + }) + + test('choosing a model turns the pane into its detail, and back returns', async ({ page }) => { + const first = railItems(page).first() + const name = await first.getAttribute('data-entity') + await first.click() + + await expect(page.locator(PANE)).toContainText(name) + await expect(page.locator(PANE)).toContainText('State') + await expect(page.locator(PANE)).not.toContainText('Right now') + + await page.locator('[data-testid="host-back"]').click() + await expect(page.locator(PANE)).toContainText('Right now') + }) + + test('the selection lives in the URL', async ({ page }) => { + const first = railItems(page).first() + const name = await first.getAttribute('data-entity') + await first.click() + await expect(page).toHaveURL(new RegExp(`[?&]sel=${encodeURIComponent(name)}`)) + }) + + test('the rail buckets by state rather than by capability', async ({ page }) => { + // The opposite of the galleries, and deliberately so: nobody opens Host + // wondering which of their models does vision. + const groups = page.locator('[data-testid^="host-rail-group-"]') + await expect(groups.first()).toBeVisible() + const ids = await groups.evaluateAll(els => els.map(e => e.dataset.testid)) + for (const id of ids) { + expect(['host-rail-group-running', 'host-rail-group-idle', 'host-rail-group-disabled']).toContain(id) + } + }) + + test('switching tabs drops a selection that belonged to the other tab', async ({ page }) => { + await railItems(page).first().click() + await expect(page.locator('[data-testid="host-back"]')).toBeVisible() + + // The other tab may legitimately be empty on a fresh host, so the contract + // is that the stale selection is gone, not that a pane appears. + await page.locator('.tab', { hasText: 'Backends' }).click() + await expect(page.locator('[data-testid="host-back"]')).toHaveCount(0) + await expect(page).not.toHaveURL(/[?&]sel=/) + }) +}) diff --git a/core/http/react-ui/e2e/manage-action-menu-position.spec.js b/core/http/react-ui/e2e/manage-action-menu-position.spec.js index 3f4301abe..138edc954 100644 --- a/core/http/react-ui/e2e/manage-action-menu-position.spec.js +++ b/core/http/react-ui/e2e/manage-action-menu-position.spec.js @@ -7,11 +7,11 @@ import { test, expect } from './coverage-fixtures.js' // inside a row whose hover `transform` re-anchored it. Fix portals the popover // to document.body, positions it before paint, and focuses without scrolling. test.describe('Manage Page - Action menu positioning', () => { - test('opening a row menu keeps scroll stable and places the menu by its trigger', async ({ page }) => { + test('opening the pane menu keeps scroll stable and places it by its trigger', async ({ page }) => { // Small viewport so the page is scrollable and a scroll jump is observable. await page.setViewportSize({ width: 1024, height: 500 }) await page.goto('/app/manage') - await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 }) + await page.locator('[data-testid="host-rail-item"]').first().click() const trigger = page.locator('button.action-menu__trigger').first() await expect(trigger).toBeVisible() diff --git a/core/http/react-ui/e2e/manage-logs-link.spec.js b/core/http/react-ui/e2e/manage-logs-link.spec.js index 22ca3835b..55311e538 100644 --- a/core/http/react-ui/e2e/manage-logs-link.spec.js +++ b/core/http/react-ui/e2e/manage-logs-link.spec.js @@ -1,11 +1,11 @@ import { test, expect } from './coverage-fixtures.js' test.describe('Manage Page - Backend Logs Link', () => { - test('row action menu exposes Backend logs entry with terminal icon', async ({ page }) => { + test('the pane action menu exposes Backend logs with a terminal icon', async ({ page }) => { await page.goto('/app/manage') - await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 }) - - // Row actions live behind the kebab (ActionMenu) — open the first row's menu. + // Actions moved out of the row and into the pane, so reaching them is now a + // selection followed by the pane's kebab. + await page.locator('[data-testid="host-rail-item"]').first().click() const trigger = page.locator('button.action-menu__trigger').first() await expect(trigger).toBeVisible() await trigger.click() @@ -17,8 +17,7 @@ test.describe('Manage Page - Backend Logs Link', () => { test('Backend logs menu item navigates to backend-logs page', async ({ page }) => { await page.goto('/app/manage') - await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 }) - + await page.locator('[data-testid="host-rail-item"]').first().click() const trigger = page.locator('button.action-menu__trigger').first() await expect(trigger).toBeVisible() await trigger.click() diff --git a/core/http/react-ui/e2e/model-editor-back-nav.spec.js b/core/http/react-ui/e2e/model-editor-back-nav.spec.js index 973d93967..695eab01c 100644 --- a/core/http/react-ui/e2e/model-editor-back-nav.spec.js +++ b/core/http/react-ui/e2e/model-editor-back-nav.spec.js @@ -46,9 +46,8 @@ test.describe('Model Editor — Back navigation', () => { test('Back returns to Manage with a "Back to System" caption', async ({ page }) => { await page.goto('/app/manage') - await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 }) - - // Open the first row's action menu and pick "Edit configuration". + // Actions live in the pane now, so select something first. + await page.locator('[data-testid="host-rail-item"]').first().click() const trigger = page.locator('button.action-menu__trigger').first() await expect(trigger).toBeVisible() await trigger.click() diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index 6ec1045be..baa872d2e 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -99,6 +99,24 @@ const MOCK_ESTIMATES = { }, }; +// The gallery is a rail plus a pane, not a table. These three helpers are the +// whole of that migration for the specs below: an entry is addressed by the +// model it carries, and the detail lives in the pane rather than in a cell +// spanning the row. +const PANE = '[data-testid="discover-pane"]'; +const railItems = (page) => page.locator('[data-testid="discover-rail-item"]'); +const railItem = (page, name) => page.locator(`[data-entity="${name}"]`); +// The use-case chips live in a popover now; opening it is idempotent so tests +// can call this without tracking whether it is already up. +const openUseCases = async (page) => { + const trigger = page.locator(".models-filters__usecase-trigger"); + if ((await page.locator(".filter-btn").count()) === 0) await trigger.click(); + await expect(page.locator(".filter-btn").first()).toBeVisible(); +}; +// Rendered means the rail has entries. The old gate waited on a column header. +const railReady = (page) => + expect(railItems(page).first()).toBeVisible({ timeout: 10_000 }); + test.describe("Models Gallery - Backend Features", () => { test.beforeEach(async ({ page }) => { await page.route("**/api/models*", (route) => { @@ -109,22 +127,18 @@ test.describe("Models Gallery - Backend Features", () => { }); await page.goto("/app/models"); // Wait for the table to render - await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ - timeout: 10_000, - }); + await railReady(page); }); - test("backend column header is visible", async ({ page }) => { - await expect(page.locator("th", { hasText: "Backend" })).toBeVisible(); - }); - - test("backend badges shown in table rows", async ({ page }) => { - const table = page.locator("table"); + test("selecting a model names its backend in the pane", async ({ page }) => { + await railItem(page, "llama-model").click(); await expect( - table.locator(".badge", { hasText: "llama-cpp" }), + page.locator(PANE).locator(".badge", { hasText: "llama-cpp" }).first(), ).toBeVisible(); + + await railItem(page, "whisper-model").click(); await expect( - table.locator(".badge", { hasText: /^whisper$/ }), + page.locator(PANE).locator(".badge", { hasText: /^whisper$/ }).first(), ).toBeVisible(); }); @@ -164,18 +178,20 @@ test.describe("Models Gallery - Backend Features", () => { .locator(".."); await dropdown.locator("text=llama-cpp").click(); - // The dropdown button should now show the selected backend instead of "All Backends" + // Scoped to the select: the rail names a backend on its own entries when + // no size estimate has arrived, so an unscoped `button span` matches those + // too and the assertion stops being about the dropdown. await expect( - page.locator("button span", { hasText: "llama-cpp" }), + page.locator(".models-filters__backend button span", { hasText: "llama-cpp" }), ).toBeVisible(); }); test("expanded row shows backend in detail", async ({ page }) => { // Click the first model row to expand it - await page.locator("tr", { hasText: "llama-model" }).click(); + await railItem(page, "llama-model").click(); // The detail view should show Backend label and value - const detail = page.locator('td[colspan="8"]'); + const detail = page.locator(PANE); await expect(detail.locator("text=Backend")).toBeVisible(); // The Backend DetailRow renders before the Variants section, which lists a // per-variant backend badge of its own, so scope to the first match. @@ -212,14 +228,13 @@ test.describe("Models Gallery - Multi-select Filters", () => { }); }); await page.goto("/app/models"); - await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ - timeout: 10_000, - }); + await railReady(page); }); test("multi-select toggle: click Chat, TTS, then Chat again", async ({ page, }) => { + await openUseCases(page); const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" }); @@ -237,6 +252,7 @@ test.describe("Models Gallery - Multi-select Filters", () => { }); test('"All" clears selection', async ({ page }) => { + await openUseCases(page); const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); const allBtn = page.locator(".filter-btn", { hasText: "All" }); @@ -249,6 +265,7 @@ test.describe("Models Gallery - Multi-select Filters", () => { }); test("query param sent correctly with multiple filters", async ({ page }) => { + await openUseCases(page); const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" }); @@ -273,6 +290,7 @@ test.describe("Models Gallery - Multi-select Filters", () => { }); test("backend greys out unavailable filters", async ({ page }) => { + await openUseCases(page); // Select llama-cpp backend via dropdown await page.locator("button", { hasText: "All Backends" }).click(); const dropdown = page @@ -303,6 +321,7 @@ test.describe("Models Gallery - Multi-select Filters", () => { }); test("backend clears incompatible filters", async ({ page }) => { + await openUseCases(page); // Select TTS filter first const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" }); await ttsBtn.click(); @@ -347,9 +366,7 @@ test.describe("Models Gallery - Fits In GPU Filter", () => { }); await page.goto("/app/models"); - await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ - timeout: 10_000, - }); + await railReady(page); }); test("fits toggle is visible when GPU resources are available", async ({ @@ -362,7 +379,7 @@ test.describe("Models Gallery - Fits In GPU Filter", () => { page, }) => { await expect( - page.locator("tr", { hasText: "stablediffusion-model" }), + railItem(page, "stablediffusion-model"), ).toBeVisible(); // The shared visually hides its native input (opacity:0;w:0;h:0), @@ -373,12 +390,12 @@ test.describe("Models Gallery - Fits In GPU Filter", () => { .click(); await expect( - page.locator("tr", { hasText: "stablediffusion-model" }), + railItem(page, "stablediffusion-model"), ).toHaveCount(0); - await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible(); + await expect(railItem(page, "llama-model")).toBeVisible(); // Unknown estimate stays visible until an explicit non-fit verdict exists. await expect( - page.locator("tr", { hasText: "unknown-model" }), + railItem(page, "unknown-model"), ).toBeVisible(); }); @@ -407,14 +424,13 @@ test.describe("Models Gallery - Empty State", () => { }); await page.goto("/app/models"); - await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ - timeout: 10_000, - }); + await railReady(page); }); test("shows empty state for filtered-out results and clear filters restores the gallery", async ({ page, }) => { + await openUseCases(page); const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); const allBtn = page.locator(".filter-btn", { hasText: "All" }); @@ -429,14 +445,14 @@ test.describe("Models Gallery - Empty State", () => { const clearBtn = page.getByRole("button", { name: "Clear filters" }); await expect(clearBtn).toBeVisible(); - await expect(page.locator("tr", { hasText: "llama-model" })).toHaveCount(0); + await expect(railItem(page, "llama-model")).toHaveCount(0); await clearBtn.click(); await expect(allBtn).toHaveClass(/active/); await expect(chatBtn).not.toHaveClass(/active/); await expect(page.locator(".empty-state")).toHaveCount(0); - await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible(); + await expect(railItem(page, "llama-model")).toBeVisible(); }); }); @@ -524,142 +540,38 @@ test.describe("Models Gallery - Variant picker", () => { }); }); await page.goto("/app/models"); - await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ - timeout: 10_000, - }); + await railReady(page); }); - const variantRow = (page) => page.locator("tr", { hasText: "llama-model" }).first(); + const variantRow = (page) => railItem(page, "llama-model"); const plainRow = (page) => - page.locator("tr", { hasText: "stablediffusion-model" }).first(); - const openMenu = (page) => - variantRow(page).getByRole("button", { name: "Choose a variant" }).click(); + railItem(page, "stablediffusion-model"); test("the listing alone fetches no variant descriptions", async ({ page }) => { // The whole point of the companion endpoint: a page load costs zero // probes no matter how many entries declare variants. - await expect(page.locator("tbody tr").first()).toBeVisible(); + await expect(railItems(page).first()).toBeVisible(); expect(variantUrls).toHaveLength(0); }); - test("an entry that declares variants shows the split-button chevron", async ({ - page, - }) => { - await expect( - variantRow(page).getByRole("button", { name: "Choose a variant" }), - ).toBeVisible(); - }); - - test("an entry without variants renders no chevron", async ({ page }) => { - await expect( - plainRow(page).getByRole("button", { name: "Choose a variant" }), - ).toHaveCount(0); - // and still offers an ordinary install - await expect( - plainRow(page).locator("button.btn-primary"), - ).toHaveCount(1); - }); - - test("an entry without variants fetches nothing even when expanded", async ({ + test("an entry without variants fetches nothing when selected", async ({ page, }) => { await plainRow(page).click(); - await expect(page.locator('td[colspan="8"]')).toBeVisible(); + await expect(page.locator(PANE)).toBeVisible(); expect(variantUrls).toHaveLength(0); }); test("plain Install sends no variant parameter", async ({ page }) => { - await plainRow(page).locator("button.btn-primary").click(); + await plainRow(page).click(); + await page.locator('[data-testid="discover-install"]').click(); await expect.poll(() => installUrls.length).toBe(1); expect(installUrls[0]).not.toContain("variant="); }); - test("opening the menu fetches the description once and caches it", async ({ - page, - }) => { - await openMenu(page); - await expect(page.locator(".action-menu")).toBeVisible(); - await expect.poll(() => variantUrls.length).toBe(1); - expect(variantUrls[0]).toContain("/api/models/variants/llama-model"); - - // Close and reopen: the cached answer must be reused. - await page.keyboard.press("Escape"); - await openMenu(page); - await expect( - page.locator(".action-menu__item", { hasText: "llama-model-q8" }), - ).toBeVisible(); - expect(variantUrls).toHaveLength(1); - }); - - test("the menu shows a loading state while the description is in flight", async ({ - page, - }) => { - let unblock; - releaseVariants = new Promise((resolve) => { - unblock = resolve; - }); - await openMenu(page); - await expect(page.locator(".action-menu")).toContainText("Loading variants"); - unblock(); - await expect( - page.locator(".action-menu__item", { hasText: "llama-model-q8" }), - ).toBeVisible(); - await expect(page.locator(".action-menu")).not.toContainText( - "Loading variants", - ); - }); - - test("the auto-selected variant is marked in the menu", async ({ page }) => { - await openMenu(page); - const menu = page.locator(".action-menu"); - await expect(menu).toBeVisible(); - const autoItem = menu.locator(".action-menu__item", { - hasText: "llama-model-q8", - }); - await expect(autoItem.locator(".badge", { hasText: "Auto" })).toBeVisible(); - // the base build is identifiable too - await expect( - menu - .locator(".action-menu__item", { hasText: "llama-model" }) - .first() - .locator(".badge", { hasText: "Base build" }), - ).toBeVisible(); - }); - - test("a variant with no memory_bytes renders as unknown, not 0", async ({ - page, - }) => { - await openMenu(page); - const mlxItem = page.locator(".action-menu__item", { - hasText: "llama-model-mlx", - }); - await expect(mlxItem).toContainText("Unknown size"); - await expect(mlxItem).not.toContainText("0 B"); - }); - - test("a variant that does not fit is still selectable", async ({ page }) => { - await openMenu(page); - const f16 = page.locator(".action-menu__item", { - hasText: "llama-model-f16", - }); - await expect(f16.locator(".badge", { hasText: "Does not fit" })).toBeVisible(); - await expect(f16).toBeEnabled(); - }); - - test("choosing a specific variant sends ?variant= on the install", async ({ - page, - }) => { - await openMenu(page); - await page - .locator(".action-menu__item", { hasText: "llama-model-mlx" }) - .click(); - await expect.poll(() => installUrls.length).toBe(1); - expect(installUrls[0]).toContain("variant=llama-model-mlx"); - }); - test("the expanded detail row lists every variant", async ({ page }) => { await variantRow(page).click(); - const detail = page.locator('td[colspan="8"]'); + const detail = page.locator(PANE); await expect(detail).toContainText("Variants"); await expect(detail).toContainText("llama-model-q8"); await expect(detail).toContainText("llama-model-mlx"); @@ -693,7 +605,7 @@ test.describe("Models Gallery - Variant picker", () => { test("only the informative status is badged", async ({ page }) => { await variantRow(page).click(); - const detail = page.locator('td[colspan="8"]'); + const detail = page.locator(PANE); await expect(detail.locator(".variant-row")).toHaveCount(4); // "Fits" was true of three rows out of four and said nothing; the row that // does not fit is the one worth marking. @@ -708,6 +620,49 @@ test.describe("Models Gallery - Variant picker", () => { ).toContainText("Auto-selected"); }); + test("selecting an entry fetches its variants once and reuses them", async ({ + page, + }) => { + // Selection is now the only trigger point, so it must pay for exactly one + // probe however many times the pane is opened. + await railItem(page, "llama-model").click(); + await expect(page.locator(PANE)).toContainText("llama-model-q8"); + await expect.poll(() => variantUrls.length).toBe(1); + expect(variantUrls[0]).toContain("/api/models/variants/llama-model"); + + await railItem(page, "stablediffusion-model").click(); + await railItem(page, "llama-model").click(); + await expect(page.locator(PANE)).toContainText("llama-model-q8"); + expect(variantUrls).toHaveLength(1); + }); + + test("the pane says the variants are loading rather than opening empty", async ({ + page, + }) => { + let unblock; + releaseVariants = new Promise((resolve) => { + unblock = resolve; + }); + await railItem(page, "llama-model").click(); + await expect(page.locator(PANE)).toContainText("Loading variants"); + unblock(); + await expect(page.locator(PANE)).toContainText("llama-model-q8"); + await expect(page.locator(PANE)).not.toContainText("Loading variants"); + }); + + test("a variant that does not fit is still installable", async ({ page }) => { + // Marked, not disabled: an explicit choice is an override the server + // honours with a warning, and only the user knows they meant it. + await railItem(page, "llama-model").click(); + const unfit = page.locator(".variant-row--unfit"); + await expect(unfit).toHaveCount(1); + await expect(unfit).toContainText("llama-model-f16"); + await expect(unfit).toBeEnabled(); + await unfit.click(); + await expect.poll(() => installUrls.length).toBe(1); + expect(installUrls[0]).toContain("variant=llama-model-f16"); + }); + test("clicking a variant row installs that variant", async ({ page }) => { await variantRow(page).click(); await page @@ -717,43 +672,6 @@ test.describe("Models Gallery - Variant picker", () => { expect(installUrls[0]).toContain("variant=llama-model-mlx"); }); - test("the menu names each build's quantization alongside backend and size", async ({ - page, - }) => { - // Without it the meta line reads "llama-cpp - 8 GB" for two builds that - // differ entirely in precision, which describes nothing the user is - // choosing between. - await openMenu(page); - await expect( - page.locator(".action-menu__item", { hasText: "llama-model-q8" }), - ).toContainText("llama-cpp · Q8_0 · 8 GB"); - }); - - test("the menu marks a build that serves faster", async ({ page }) => { - // A compact marker, not a sentence: the dropdown has room for the token - // and the detail row carries the spelled-out name. - await openMenu(page); - await expect( - page - .locator(".action-menu__item", { hasText: "llama-model-q8" }) - .locator(".badge", { hasText: "DFLASH" }), - ).toBeVisible(); - }); - - test("a build naming no quantization drops the segment rather than blanking", async ({ - page, - }) => { - // The degrade contract in the compact surface: no empty segment, no - // dangling separator, and above all no "undefined". - await openMenu(page); - const item = page.locator(".action-menu__item", { - hasText: "llama-model-mlx", - }); - await expect(item).toContainText("mlx · Unknown size"); - await expect(item).not.toContainText("undefined"); - await expect(item).not.toContainText("· ·"); - }); - test("the detail row gives quantization its own column", async ({ page }) => { await variantRow(page).click(); const detail = page.locator(".variant-list"); @@ -901,11 +819,9 @@ test.describe("Models Gallery - Variant details", () => { }), ); await page.goto("/app/models"); - await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ - timeout: 10_000, - }); + await railReady(page); // Expanding the parent is what puts the variant list on screen. - await page.locator("tr", { hasText: "llama-model" }).first().click(); + await railItem(page, "llama-model").click(); await expect(page.locator(".variant-row")).toHaveCount(4); }); @@ -1030,6 +946,11 @@ test.describe("Models Gallery - Variant details", () => { page, }) => { const info = infoFor(page, "llama-model-q8"); + // The pane is opened by clicking a rail entry, which is a real