From a473a32678947d00a0995b857e16325c9b67db2a Mon Sep 17 00:00:00 2001 From: Ching <62589446+Oceankj@users.noreply.github.com> Date: Fri, 29 May 2026 03:39:33 -0700 Subject: [PATCH] test(react-ui): cover models gallery empty-state reset flow (#10019) Exercise the filtered empty-state path in the models gallery and verify that the clear-filters action restores the list and resets the filter selection. Assisted-by: Codex:gpt-5 Signed-off-by: Ching Kao <0980124jim@gmail.com> --- core/http/react-ui/e2e/models-gallery.spec.js | 488 +++++++++++------- 1 file changed, 307 insertions(+), 181 deletions(-) diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index 242a6d621..cb1467b39 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -1,28 +1,52 @@ -import { test, expect } from './coverage-fixtures.js' +import { test, expect } from "./coverage-fixtures.js"; const MOCK_MODELS_RESPONSE = { models: [ - { name: 'llama-model', description: 'A llama model', backend: 'llama-cpp', installed: false, tags: ['chat'] }, - { name: 'whisper-model', description: 'A whisper model', backend: 'whisper', installed: true, tags: ['transcript'] }, - { name: 'stablediffusion-model', description: 'An image model', backend: 'stablediffusion', installed: false, tags: ['sd'] }, - { name: 'unknown-model', description: 'No backend', backend: '', installed: false, tags: [] }, + { + name: "llama-model", + description: "A llama model", + backend: "llama-cpp", + installed: false, + tags: ["chat"], + }, + { + name: "whisper-model", + description: "A whisper model", + backend: "whisper", + installed: true, + tags: ["transcript"], + }, + { + name: "stablediffusion-model", + description: "An image model", + backend: "stablediffusion", + installed: false, + tags: ["sd"], + }, + { + name: "unknown-model", + description: "No backend", + backend: "", + installed: false, + tags: [], + }, ], - allBackends: ['llama-cpp', 'stablediffusion', 'whisper'], - allTags: ['chat', 'sd', 'transcript'], + allBackends: ["llama-cpp", "stablediffusion", "whisper"], + allTags: ["chat", "sd", "transcript"], availableModels: 4, installedModels: 1, totalPages: 1, currentPage: 1, -} +}; const MOCK_GPU_RESOURCES_RESPONSE = { - type: 'gpu', + type: "gpu", available: true, gpus: [ { index: 0, - name: 'Mock GPU', - vendor: 'nvidia', + name: "Mock GPU", + vendor: "nvidia", total_vram: 12 * 1024 * 1024 * 1024, used_vram: 2 * 1024 * 1024 * 1024, free_vram: 10 * 1024 * 1024 * 1024, @@ -36,272 +60,374 @@ const MOCK_GPU_RESOURCES_RESPONSE = { usage_percent: 16.7, gpu_count: 1, }, -} +}; const MOCK_ESTIMATES = { - 'llama-model': { + "llama-model": { sizeBytes: 4 * 1024 * 1024 * 1024, - sizeDisplay: '4.00 GB', + sizeDisplay: "4.00 GB", estimates: { - '8192': { + 8192: { vramBytes: 8 * 1024 * 1024 * 1024, - vramDisplay: '8.00 GB', + vramDisplay: "8.00 GB", }, }, }, - 'whisper-model': { + "whisper-model": { sizeBytes: 1 * 1024 * 1024 * 1024, - sizeDisplay: '1.00 GB', + sizeDisplay: "1.00 GB", estimates: { - '8192': { + 8192: { vramBytes: 2 * 1024 * 1024 * 1024, - vramDisplay: '2.00 GB', + vramDisplay: "2.00 GB", }, }, }, - 'stablediffusion-model': { + "stablediffusion-model": { sizeBytes: 8 * 1024 * 1024 * 1024, - sizeDisplay: '8.00 GB', + sizeDisplay: "8.00 GB", estimates: { - '8192': { + 8192: { vramBytes: 16 * 1024 * 1024 * 1024, - vramDisplay: '16.00 GB', + vramDisplay: "16.00 GB", }, }, }, -} +}; -test.describe('Models Gallery - Backend Features', () => { +test.describe("Models Gallery - Backend Features", () => { test.beforeEach(async ({ page }) => { - await page.route('**/api/models*', (route) => { + await page.route("**/api/models*", (route) => { route.fulfill({ - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify(MOCK_MODELS_RESPONSE), - }) - }) - await page.goto('/app/models') + }); + }); + await page.goto("/app/models"); // Wait for the table to render - await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible({ timeout: 10_000 }) - }) + await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ + timeout: 10_000, + }); + }); - test('backend column header is visible', async ({ page }) => { - await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible() - }) + 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') - await expect(table.locator('.badge', { hasText: 'llama-cpp' })).toBeVisible() - await expect(table.locator('.badge', { hasText: /^whisper$/ })).toBeVisible() - }) + test("backend badges shown in table rows", async ({ page }) => { + const table = page.locator("table"); + await expect( + table.locator(".badge", { hasText: "llama-cpp" }), + ).toBeVisible(); + await expect( + table.locator(".badge", { hasText: /^whisper$/ }), + ).toBeVisible(); + }); - test('backend dropdown is visible', async ({ page }) => { - await expect(page.locator('button', { hasText: 'All Backends' })).toBeVisible() - }) + test("backend dropdown is visible", async ({ page }) => { + await expect( + page.locator("button", { hasText: "All Backends" }), + ).toBeVisible(); + }); - test('clicking backend dropdown opens searchable panel', async ({ page }) => { - await page.locator('button', { hasText: 'All Backends' }).click() - await expect(page.locator('input[placeholder="Search backends..."]')).toBeVisible() - }) + test("clicking backend dropdown opens searchable panel", async ({ page }) => { + await page.locator("button", { hasText: "All Backends" }).click(); + await expect( + page.locator('input[placeholder="Search backends..."]'), + ).toBeVisible(); + }); - test('typing in search filters dropdown options', async ({ page }) => { - await page.locator('button', { hasText: 'All Backends' }).click() - const searchInput = page.locator('input[placeholder="Search backends..."]') - await searchInput.fill('llama') + test("typing in search filters dropdown options", async ({ page }) => { + await page.locator("button", { hasText: "All Backends" }).click(); + const searchInput = page.locator('input[placeholder="Search backends..."]'); + await searchInput.fill("llama"); // llama-cpp option should be visible, whisper should not - const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..') .locator('..') - await expect(dropdown.locator('text=llama-cpp')).toBeVisible() - await expect(dropdown.locator('text=whisper')).not.toBeVisible() - }) + const dropdown = page + .locator('input[placeholder="Search backends..."]') + .locator("..") + .locator(".."); + await expect(dropdown.locator("text=llama-cpp")).toBeVisible(); + await expect(dropdown.locator("text=whisper")).not.toBeVisible(); + }); - test('selecting a backend updates the dropdown label', async ({ page }) => { - await page.locator('button', { hasText: 'All Backends' }).click() + test("selecting a backend updates the dropdown label", async ({ page }) => { + await page.locator("button", { hasText: "All Backends" }).click(); // Click the llama-cpp option within the dropdown (not the table badge) - const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..').locator('..') - await dropdown.locator('text=llama-cpp').click() + const dropdown = page + .locator('input[placeholder="Search backends..."]') + .locator("..") + .locator(".."); + await dropdown.locator("text=llama-cpp").click(); // The dropdown button should now show the selected backend instead of "All Backends" - await expect(page.locator('button span', { hasText: 'llama-cpp' })).toBeVisible() - }) + await expect( + page.locator("button span", { hasText: "llama-cpp" }), + ).toBeVisible(); + }); - test('expanded row shows backend in detail', async ({ page }) => { + 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 page.locator("tr", { hasText: "llama-model" }).click(); // The detail view should show Backend label and value - const detail = page.locator('td[colspan="8"]') - await expect(detail.locator('text=Backend')).toBeVisible() - await expect(detail.locator('text=llama-cpp')).toBeVisible() - }) -}) + const detail = page.locator('td[colspan="8"]'); + await expect(detail.locator("text=Backend")).toBeVisible(); + await expect(detail.locator("text=llama-cpp")).toBeVisible(); + }); +}); const BACKEND_USECASES_MOCK = { - 'llama-cpp': ['chat', 'embeddings', 'vision'], - 'whisper': ['transcript'], - 'stablediffusion': ['image'], -} + "llama-cpp": ["chat", "embeddings", "vision"], + whisper: ["transcript"], + stablediffusion: ["image"], +}; -test.describe('Models Gallery - Multi-select Filters', () => { +const EMPTY_FILTERED_RESPONSE = { + ...MOCK_MODELS_RESPONSE, + models: [], + availableModels: 0, + totalPages: 1, + currentPage: 1, +}; + +test.describe("Models Gallery - Multi-select Filters", () => { test.beforeEach(async ({ page }) => { - await page.route('**/api/models*', (route) => { + await page.route("**/api/models*", (route) => { route.fulfill({ - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify(MOCK_MODELS_RESPONSE), - }) - }) - await page.route('**/api/backends/usecases', (route) => { + }); + }); + await page.route("**/api/backends/usecases", (route) => { route.fulfill({ - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify(BACKEND_USECASES_MOCK), - }) - }) - await page.goto('/app/models') - await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible({ timeout: 10_000 }) - }) + }); + }); + await page.goto("/app/models"); + await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ + timeout: 10_000, + }); + }); - test('multi-select toggle: click Chat, TTS, then Chat again', async ({ page }) => { - const chatBtn = page.locator('.filter-btn', { hasText: 'Chat' }) - const ttsBtn = page.locator('.filter-btn', { hasText: 'TTS' }) + test("multi-select toggle: click Chat, TTS, then Chat again", async ({ + page, + }) => { + const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); + const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" }); - await chatBtn.click() - await expect(chatBtn).toHaveClass(/active/) + await chatBtn.click(); + await expect(chatBtn).toHaveClass(/active/); - await ttsBtn.click() - await expect(chatBtn).toHaveClass(/active/) - await expect(ttsBtn).toHaveClass(/active/) + await ttsBtn.click(); + await expect(chatBtn).toHaveClass(/active/); + await expect(ttsBtn).toHaveClass(/active/); // Click Chat again to deselect it - await chatBtn.click() - await expect(chatBtn).not.toHaveClass(/active/) - await expect(ttsBtn).toHaveClass(/active/) - }) + await chatBtn.click(); + await expect(chatBtn).not.toHaveClass(/active/); + await expect(ttsBtn).toHaveClass(/active/); + }); test('"All" clears selection', async ({ page }) => { - const chatBtn = page.locator('.filter-btn', { hasText: 'Chat' }) - const allBtn = page.locator('.filter-btn', { hasText: 'All' }) + const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); + const allBtn = page.locator(".filter-btn", { hasText: "All" }); - await chatBtn.click() - await expect(chatBtn).toHaveClass(/active/) + await chatBtn.click(); + await expect(chatBtn).toHaveClass(/active/); - await allBtn.click() - await expect(allBtn).toHaveClass(/active/) - await expect(chatBtn).not.toHaveClass(/active/) - }) + await allBtn.click(); + await expect(allBtn).toHaveClass(/active/); + await expect(chatBtn).not.toHaveClass(/active/); + }); - test('query param sent correctly with multiple filters', async ({ page }) => { - const chatBtn = page.locator('.filter-btn', { hasText: 'Chat' }) - const ttsBtn = page.locator('.filter-btn', { hasText: 'TTS' }) + test("query param sent correctly with multiple filters", async ({ page }) => { + const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); + const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" }); // Click Chat and wait for its request to settle - await chatBtn.click() - await page.waitForResponse(resp => resp.url().includes('/api/models')) + await chatBtn.click(); + await page.waitForResponse((resp) => resp.url().includes("/api/models")); // Now click TTS and capture the resulting request const [request] = await Promise.all([ - page.waitForRequest(req => { - if (!req.url().includes('/api/models')) return false - const u = new URL(req.url()) - const tag = u.searchParams.get('tag') - return tag && tag.split(',').length >= 2 + page.waitForRequest((req) => { + if (!req.url().includes("/api/models")) return false; + const u = new URL(req.url()); + const tag = u.searchParams.get("tag"); + return tag && tag.split(",").length >= 2; }), ttsBtn.click(), - ]) + ]); - const url = new URL(request.url()) - const tags = url.searchParams.get('tag').split(',').sort() - expect(tags).toEqual(['chat', 'tts']) - }) + const url = new URL(request.url()); + const tags = url.searchParams.get("tag").split(",").sort(); + expect(tags).toEqual(["chat", "tts"]); + }); - test('backend greys out unavailable filters', async ({ page }) => { + test("backend greys out unavailable filters", async ({ page }) => { // Select llama-cpp backend via dropdown - await page.locator('button', { hasText: 'All Backends' }).click() - const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..').locator('..') - await dropdown.locator('text=llama-cpp').click() + await page.locator("button", { hasText: "All Backends" }).click(); + const dropdown = page + .locator('input[placeholder="Search backends..."]') + .locator("..") + .locator(".."); + await dropdown.locator("text=llama-cpp").click(); // Wait for filter state to update - const ttsBtn = page.locator('.filter-btn', { hasText: 'TTS' }) - const sttBtn = page.locator('.filter-btn', { hasText: 'STT' }) - const imageBtn = page.locator('.filter-btn', { hasText: 'Image' }) + const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" }); + const sttBtn = page.locator(".filter-btn", { hasText: "STT" }); + const imageBtn = page.locator(".filter-btn", { hasText: "Image" }); // TTS, STT, Image should be disabled for llama-cpp - await expect(ttsBtn).toBeDisabled() - await expect(sttBtn).toBeDisabled() - await expect(imageBtn).toBeDisabled() + await expect(ttsBtn).toBeDisabled(); + await expect(sttBtn).toBeDisabled(); + await expect(imageBtn).toBeDisabled(); // Chat, Embeddings, Vision should remain enabled - const chatBtn = page.locator('.filter-btn', { hasText: 'Chat' }) - const embBtn = page.locator('.filter-btn', { hasText: 'Embeddings' }) - const visBtn = page.locator('.filter-btn', { hasText: 'Vision' }) - await expect(chatBtn).toBeEnabled() - await expect(embBtn).toBeEnabled() - await expect(visBtn).toBeEnabled() - }) + const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); + const embBtn = page.locator(".filter-btn", { hasText: "Embeddings" }); + const visBtn = page.locator(".filter-btn", { hasText: "Vision" }); + await expect(chatBtn).toBeEnabled(); + await expect(embBtn).toBeEnabled(); + await expect(visBtn).toBeEnabled(); + }); - test('backend clears incompatible filters', async ({ page }) => { + test("backend clears incompatible filters", async ({ page }) => { // Select TTS filter first - const ttsBtn = page.locator('.filter-btn', { hasText: 'TTS' }) - await ttsBtn.click() - await expect(ttsBtn).toHaveClass(/active/) + const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" }); + await ttsBtn.click(); + await expect(ttsBtn).toHaveClass(/active/); // Now select llama-cpp backend (which doesn't support TTS) - await page.locator('button', { hasText: 'All Backends' }).click() - const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..').locator('..') - await dropdown.locator('text=llama-cpp').click() + await page.locator("button", { hasText: "All Backends" }).click(); + const dropdown = page + .locator('input[placeholder="Search backends..."]') + .locator("..") + .locator(".."); + await dropdown.locator("text=llama-cpp").click(); // TTS should be auto-removed from selection - await expect(ttsBtn).not.toHaveClass(/active/) - }) -}) + await expect(ttsBtn).not.toHaveClass(/active/); + }); +}); -test.describe('Models Gallery - Fits In GPU Filter', () => { +test.describe("Models Gallery - Fits In GPU Filter", () => { test.beforeEach(async ({ page }) => { - await page.route('**/api/models*', (route) => { + await page.route("**/api/models*", (route) => { route.fulfill({ - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify(MOCK_MODELS_RESPONSE), - }) - }) + }); + }); - await page.route('**/api/resources', (route) => { + await page.route("**/api/resources", (route) => { route.fulfill({ - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify(MOCK_GPU_RESOURCES_RESPONSE), - }) - }) + }); + }); - await page.route('**/api/models/estimate/*', (route) => { - const url = new URL(route.request().url()) - const id = decodeURIComponent(url.pathname.split('/').pop() || '') + await page.route("**/api/models/estimate/*", (route) => { + const url = new URL(route.request().url()); + const id = decodeURIComponent(url.pathname.split("/").pop() || ""); route.fulfill({ - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify(MOCK_ESTIMATES[id] || {}), - }) - }) + }); + }); - await page.goto('/app/models') - await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible({ timeout: 10_000 }) - }) + await page.goto("/app/models"); + await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ + timeout: 10_000, + }); + }); - test('fits toggle is visible when GPU resources are available', async ({ page }) => { - await expect(page.getByText('Fits in GPU')).toBeVisible() - }) + test("fits toggle is visible when GPU resources are available", async ({ + page, + }) => { + await expect(page.getByText("Fits in GPU")).toBeVisible(); + }); - test('enabling fits filter hides models that exceed available VRAM', async ({ page }) => { - await expect(page.locator('tr', { hasText: 'stablediffusion-model' })).toBeVisible() + test("enabling fits filter hides models that exceed available VRAM", async ({ + page, + }) => { + await expect( + page.locator("tr", { hasText: "stablediffusion-model" }), + ).toBeVisible(); // The shared visually hides its native input (opacity:0;w:0;h:0), // so .check() can't interact with it directly — click the visible track. - await page.locator('label.filter-bar-group__toggle', { hasText: 'Fits in GPU' }).locator('.toggle__track').click() + await page + .locator("label.filter-bar-group__toggle", { hasText: "Fits in GPU" }) + .locator(".toggle__track") + .click(); - await expect(page.locator('tr', { hasText: 'stablediffusion-model' })).toHaveCount(0) - await expect(page.locator('tr', { hasText: 'llama-model' })).toBeVisible() + await expect( + page.locator("tr", { hasText: "stablediffusion-model" }), + ).toHaveCount(0); + await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible(); // Unknown estimate stays visible until an explicit non-fit verdict exists. - await expect(page.locator('tr', { hasText: 'unknown-model' })).toBeVisible() - }) + await expect( + page.locator("tr", { hasText: "unknown-model" }), + ).toBeVisible(); + }); - test('fits filter state persists after reload', async ({ page }) => { - await page.locator('label.filter-bar-group__toggle', { hasText: 'Fits in GPU' }).locator('.toggle__track').click() - await page.reload() - await expect(page.getByLabel('Fits in GPU')).toBeChecked() - }) -}) + test("fits filter state persists after reload", async ({ page }) => { + await page + .locator("label.filter-bar-group__toggle", { hasText: "Fits in GPU" }) + .locator(".toggle__track") + .click(); + await page.reload(); + await expect(page.getByLabel("Fits in GPU")).toBeChecked(); + }); +}); + +test.describe("Models Gallery - Empty State", () => { + test.beforeEach(async ({ page }) => { + await page.route("**/api/models*", (route) => { + const url = new URL(route.request().url()); + const tag = url.searchParams.get("tag"); + const body = + tag === "chat" ? EMPTY_FILTERED_RESPONSE : MOCK_MODELS_RESPONSE; + + route.fulfill({ + contentType: "application/json", + body: JSON.stringify(body), + }); + }); + + await page.goto("/app/models"); + await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("shows empty state for filtered-out results and clear filters restores the gallery", async ({ + page, + }) => { + const chatBtn = page.locator(".filter-btn", { hasText: "Chat" }); + const allBtn = page.locator(".filter-btn", { hasText: "All" }); + + await chatBtn.click(); + + await expect(page.locator(".empty-state-title")).toHaveText( + "No models found", + ); + await expect(page.locator(".empty-state-text")).toHaveText( + "No models match your current search or filters.", + ); + + const clearBtn = page.getByRole("button", { name: "Clear filters" }); + await expect(clearBtn).toBeVisible(); + await expect(page.locator("tr", { hasText: "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(); + }); +});