diff --git a/core/http/react-ui/e2e/alias-template.spec.js b/core/http/react-ui/e2e/alias-template.spec.js index f3b1a0ca08b5..e9b13ba61598 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 11b1791891ac..0422699f6cb3 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' }) + 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) + }) - await expect(cell).toHaveAttribute('title', STRIPPED_DESCRIPTION) + 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('a backend with no description still shows the placeholder', async ({ page }) => { - const row = page.locator('tr', { hasText: 'plain-backend' }) +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 is one flat list', async ({ page }) => { + // Same reason as Discover: the backend listing paginates too, so a bucket + // described 21 rows out of 1,012 and the chips filter the same axis. + await expect(page.locator('[data-testid^="backends-rail-group-"]')).toHaveCount(0) + }) - await expect(row.locator('span[title=""]')).toHaveText('-') + 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 000000000000..3d4987ca710b --- /dev/null +++ b/core/http/react-ui/e2e/discover-height.spec.js @@ -0,0 +1,70 @@ +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) + expect(await railHeight()).toBe(beforeRail) + + // 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/host-split-view.spec.js b/core/http/react-ui/e2e/host-split-view.spec.js new file mode 100644 index 000000000000..e9875bb93578 --- /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 3f4301abe554..138edc954604 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 22ca3835b711..55311e538c74 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 973d93967bce..695eab01c5ed 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 6ec1045beadc..efa755aca964 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,50 +620,56 @@ test.describe("Models Gallery - Variant picker", () => { ).toContainText("Auto-selected"); }); - test("clicking a variant row installs that variant", async ({ page }) => { - await variantRow(page).click(); - await page - .locator(".variant-row", { hasText: "llama-model-mlx" }) - .click(); - await expect.poll(() => installUrls.length).toBe(1); - expect(installUrls[0]).toContain("variant=llama-model-mlx"); - }); - - test("the menu names each build's quantization alongside backend and size", async ({ + test("selecting an entry fetches its variants once and reuses them", 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"); - }); + // 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"); - 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(); + 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("a build naming no quantization drops the segment rather than blanking", async ({ + test("the pane says the variants are loading rather than opening empty", 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", + let unblock; + releaseVariants = new Promise((resolve) => { + unblock = resolve; }); - await expect(item).toContainText("mlx · Unknown size"); - await expect(item).not.toContainText("undefined"); - await expect(item).not.toContainText("· ·"); + 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 + .locator(".variant-row", { hasText: "llama-model-mlx" }) + .click(); + await expect.poll(() => installUrls.length).toBe(1); + expect(installUrls[0]).toContain("variant=llama-model-mlx"); }); test("the detail row gives quantization its own column", async ({ page }) => { @@ -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