Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions core/http/react-ui/e2e/alias-template.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
86 changes: 77 additions & 9 deletions core/http/react-ui/e2e/backends-management.spec.js
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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.
Expand All @@ -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')
})
})
70 changes: 70 additions & 0 deletions core/http/react-ui/e2e/discover-height.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
69 changes: 69 additions & 0 deletions core/http/react-ui/e2e/host-split-view.spec.js
Original file line number Diff line number Diff line change
@@ -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=/)
})
})
4 changes: 2 additions & 2 deletions core/http/react-ui/e2e/manage-action-menu-position.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 5 additions & 6 deletions core/http/react-ui/e2e/manage-logs-link.spec.js
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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()
Expand Down
5 changes: 2 additions & 3 deletions core/http/react-ui/e2e/model-editor-back-nav.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading