mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
* test(ui): fix stale and timing-bound Home and NotFound specs The Home slash menu spec dropped /skill from its expected list, but the test server always keeps built-in skills, so /skill is offered. Restore the expected list. The NotFound spec loaded fourteen removed addresses in one test. A CI trace showed about four seconds per address, so the test passed the 30 second limit before the loop ended. Run one test per address. The Home skeleton spec delayed the answer by a fixed 800 ms, which a slow runner can spend before it paints. Hold the answer until the skeleton was seen. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): register new Ginkgo cases in the suite inventory The harness compares the cases a suite reports with the cases listed in scripts/addons/suites.json. The Sandbox document skills change added five packaging cases that no entry listed, and the apps entry listed 265 cases while its four reports list 274. Both suites failed with an inventory mismatch although every spec passed. List the five packaging cases in the contracts entry and its packaging report, and the nine missing apps cases in the apps entry. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): let the route change finish before counting add-on reads The apps polling spec leaves the add-ons page for a lazily loaded route while the page clock is paused. The transition does not finish until the clock moves, so the first step of the 180 second run still saw the add-ons page mounted and fired one more read. The spec failed in four of six local runs. Its sibling spec already advances the clock and settles before it counts. Do the same here, and wait for the NotFound page so the check is made on a page that no longer holds the add-ons list. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(studio): stop the scoped-chat spec timing out on the nib harness The spec "opens a scoped chat with load_skill without reentering session access resolution" opened its chat on the real nib harness. Starting a nib session takes about a second under -race and more on a loaded CI runner, so Open sometimes passed the two second budget and the spec reported a re-entrant resolution that did not exist. The distributed test job failed on it while the plain tests job passed. Run the spec on the stub harness, so Open only builds the chat tools, which is what the spec is about. Widen the hang guard to 30 seconds: a real re-entry blocks on the open lock for good, so any finite bound tells it from a slow run. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
673 lines
30 KiB
JavaScript
673 lines
30 KiB
JavaScript
import { test, expect } from './coverage-fixtures.js'
|
|
import { mockHome, mockUser, LOADED } from './home-fixtures.js'
|
|
import { installStudioAddons } from './studio-addon.js'
|
|
import { app, mockAddons, stubAddonRoutes } from './addons-fixtures.js'
|
|
|
|
// The Home console: command bar, slash menu, memory strip, resume list, first
|
|
// run and the phone layout. Data is stubbed the way the other Home specs do it;
|
|
// the resume strip reads the newest chat the server keeps.
|
|
|
|
const GB = 1024 * 1024 * 1024
|
|
|
|
async function ready(page) {
|
|
await page.goto('/app')
|
|
await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 })
|
|
}
|
|
|
|
test.describe('Home slash menu', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockHome(page)
|
|
})
|
|
|
|
test('typing a slash opens the grouped action list', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
const list = page.getByRole('listbox', { name: 'Actions' })
|
|
await expect(list).toBeVisible()
|
|
// Grouped, with a label above each group.
|
|
await expect(list.locator('.dk-cmd-label').first()).toHaveText('Chat')
|
|
await expect(list.getByRole('option').first()).toHaveAttribute('aria-selected', 'true')
|
|
})
|
|
|
|
test('only actions with a destination are offered', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
const names = await page.locator('.dk-cmd-name').allTextContents()
|
|
// Native Apps opens the generic installed list, never a promoted package.
|
|
// Home's own commands first; the commands it shares with a chat follow:
|
|
// /attach, /skill, /skills and one per skill of the server, then /help.
|
|
expect(names.slice(0, 15)).toEqual([
|
|
'/model', '/new', '/chat', '/assistant', '/models',
|
|
'/gallery', '/installed', '/import', '/stop',
|
|
// No installation advice for a particular provider.
|
|
'/agents', '/mcp', '/memory', '/apps', '/settings', '/docs',
|
|
])
|
|
// The test server always keeps built-in skills, so /skill is offered.
|
|
expect(names.slice(15, 18)).toEqual(['/attach', '/skill', '/skills'])
|
|
expect(names[names.length - 1]).toBe('/help')
|
|
// A chat that does not exist yet has nothing to compact or clear.
|
|
expect(names).not.toContain('/compact')
|
|
expect(names).not.toContain('/clear')
|
|
await expect(page.getByTestId('home-slash-apps')).toBeVisible()
|
|
await expect(page.getByTestId('home-slash-studioInstall')).toHaveCount(0)
|
|
await expect(page.getByTestId('home-slash-studio')).toHaveCount(0)
|
|
})
|
|
|
|
test('the /agents, /mcp and /memory commands open their pages', async ({ page }) => {
|
|
for (const [cmd, url] of [['agents', /\/app\/agents$/], ['mcp', /\/app\/mcp-connectors$/], ['memory', /\/app\/collections$/]]) {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
await page.getByTestId(`home-slash-${cmd}`).click()
|
|
await expect(page).toHaveURL(url)
|
|
}
|
|
})
|
|
|
|
test('the /skills command opens the Skills page', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/skills')
|
|
await page.getByTestId('home-slash-skills').click()
|
|
await expect(page).toHaveURL(/\/app\/skills$/)
|
|
})
|
|
|
|
// Studio is an add-on: its command is offered only while it is installed.
|
|
test('the /studio command appears once the Studio add-on is installed', async ({ page }) => {
|
|
await installStudioAddons(page, { ids: ['studio'] })
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
await expect(page.getByTestId('home-slash-addon:studio:open:alias')).toBeVisible()
|
|
await page.getByTestId('home-slash-addon:studio:open:alias').click()
|
|
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio/)
|
|
})
|
|
|
|
test('the /studio command is not offered while the Studio add-on is not installed', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/stu')
|
|
await expect(page.getByTestId('home-slash-studio')).toHaveCount(0)
|
|
})
|
|
|
|
// The LocalAI assistant is a chat started with the management tools.
|
|
test('/assistant starts an assistant chat and opens it', async ({ page }) => {
|
|
let body = null
|
|
await page.route('**/api/chats', route => {
|
|
if (route.request().method() !== 'POST') return route.fallback()
|
|
body = route.request().postDataJSON()
|
|
return route.fulfill({ status: 201, json: { id: 'as1', title: 'LocalAI Assistant', model: body.model, kind: 'chat', assistant: true } })
|
|
})
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
await page.getByTestId('home-slash-assistant').click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/as1/)
|
|
expect(body).toMatchObject({ assistant: true, title: 'LocalAI Assistant' })
|
|
})
|
|
|
|
test('the assistant entry and command are hidden while the setting is off', async ({ page }) => {
|
|
await mockHome(page, { features: { distributed: false, localai_assistant: false } })
|
|
await ready(page)
|
|
await expect(page.getByTestId('home-assistant')).toHaveCount(0)
|
|
await page.locator('.home-textarea').fill('/ass')
|
|
await expect(page.getByTestId('home-slash-assistant')).toHaveCount(0)
|
|
})
|
|
|
|
test('filters as you type and marks the typed part', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/ga')
|
|
const options = page.getByRole('option')
|
|
await expect(options).toHaveCount(1)
|
|
await expect(options.first()).toContainText('/gallery')
|
|
await expect(options.first().locator('mark')).toHaveText('/ga')
|
|
})
|
|
|
|
test('matches on the label too', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/documentation')
|
|
await expect(page.getByRole('option')).toHaveCount(1)
|
|
await expect(page.getByRole('option').first()).toContainText('/docs')
|
|
})
|
|
|
|
test('arrow keys move the choice and Enter runs it', async ({ page }) => {
|
|
await ready(page)
|
|
const box = page.locator('.home-textarea')
|
|
await box.fill('/')
|
|
await box.press('ArrowDown')
|
|
await expect(page.getByRole('option').nth(1)).toHaveAttribute('aria-selected', 'true')
|
|
await box.press('ArrowUp')
|
|
await box.press('ArrowUp')
|
|
// Wraps to the last action.
|
|
await expect(page.getByRole('option').last()).toHaveAttribute('aria-selected', 'true')
|
|
await box.fill('/gal')
|
|
await box.press('Enter')
|
|
await expect(page).toHaveURL(/\/app\/models$/)
|
|
})
|
|
|
|
test('Escape closes the menu and keeps the text', async ({ page }) => {
|
|
await ready(page)
|
|
const box = page.locator('.home-textarea')
|
|
await box.fill('/ga')
|
|
await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible()
|
|
await box.press('Escape')
|
|
await expect(page.getByRole('listbox', { name: 'Actions' })).toHaveCount(0)
|
|
await expect(box).toHaveValue('/ga')
|
|
})
|
|
|
|
test('a message with a space is a message, not a command', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/etc/hosts what is this file')
|
|
await expect(page.getByRole('listbox', { name: 'Actions' })).toHaveCount(0)
|
|
})
|
|
|
|
test('a click on an action runs it and clears the box', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
await page.getByTestId('home-slash-installed').click()
|
|
await expect(page).toHaveURL(/\/app\/models\?view=installed/)
|
|
})
|
|
|
|
test('"/" outside a text field starts a command', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-greeting').click()
|
|
await page.keyboard.press('/')
|
|
await expect(page.locator('.home-textarea')).toBeFocused()
|
|
await expect(page.locator('.home-textarea')).toHaveValue('/')
|
|
await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible()
|
|
})
|
|
|
|
test('/model opens the model list', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/model')
|
|
await page.locator('.home-textarea').press('Enter')
|
|
await expect(page.getByRole('listbox', { name: 'Model' })).toBeVisible()
|
|
})
|
|
|
|
test('/stop opens the memory strip', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/stop')
|
|
await page.locator('.home-textarea').press('Enter')
|
|
await expect(page.locator('.home-loaded')).toBeVisible()
|
|
})
|
|
|
|
test('/new opens the chats page on the chosen model', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/new')
|
|
await page.locator('.home-textarea').press('Enter')
|
|
await expect(page).toHaveURL(/\/app\/chats\?model=qwen3-8b-instruct$/)
|
|
})
|
|
|
|
test('a person who is not an admin sees only the actions they can use', async ({ page }) => {
|
|
await mockUser(page)
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
const names = await page.locator('.dk-cmd-name').allTextContents()
|
|
expect(names.slice(0, 5)).toEqual(['/model', '/new', '/chat', '/models', '/docs'])
|
|
expect(names).not.toContain('/gallery')
|
|
expect(names[names.length - 1]).toBe('/help')
|
|
})
|
|
|
|
test('and the library row keeps only the documentation link', async ({ page }) => {
|
|
await mockUser(page)
|
|
await ready(page)
|
|
const row = page.getByTestId('home-library')
|
|
await expect(row.getByText(/documentation/i)).toBeVisible()
|
|
await expect(row.getByText(/browse gallery/i)).toHaveCount(0)
|
|
await expect(page.getByTestId('home-assistant-tip')).toHaveCount(0)
|
|
})
|
|
})
|
|
|
|
test.describe('Home model chip', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockHome(page)
|
|
})
|
|
|
|
test('shows warm and cold models and picks with the keyboard', async ({ page }) => {
|
|
await ready(page)
|
|
const chip = page.getByTestId('home-model-chip')
|
|
await expect(chip).toContainText('qwen3-8b-instruct')
|
|
await chip.click()
|
|
const list = page.getByRole('listbox', { name: 'Model' })
|
|
await expect(list.getByRole('option')).toHaveCount(4)
|
|
await expect(list.getByRole('option', { name: /qwen3-8b-instruct/ }).locator('.home-dot')).not.toHaveClass(/home-dot--cold/)
|
|
await expect(list.getByRole('option', { name: /gemma-4-e4b/ }).locator('.home-dot')).toHaveClass(/home-dot--cold/)
|
|
await expect(list.getByRole('option', { name: /gemma-4-e4b/ })).toContainText('not loaded')
|
|
await page.keyboard.press('ArrowDown')
|
|
await page.keyboard.press('Enter')
|
|
await expect(chip).toContainText('gemma-4-e4b-it-qat-q4_0')
|
|
await expect(list).toHaveCount(0)
|
|
await expect(chip).toBeFocused()
|
|
})
|
|
|
|
test('keeps Home auto-selection and remembers an explicit model', async ({ page }) => {
|
|
await ready(page)
|
|
const chip = page.getByTestId('home-model-chip')
|
|
await expect(chip).toContainText('qwen3-8b-instruct')
|
|
await chip.click()
|
|
await page.getByRole('option', { name: /gemma-4-e4b/ }).click()
|
|
expect(await page.evaluate(() => localStorage.getItem('localai_last_model:FLAG_CHAT'))).toBe('gemma-4-e4b-it-qat-q4_0')
|
|
await page.reload()
|
|
await expect(chip).toContainText('gemma-4-e4b-it-qat-q4_0')
|
|
})
|
|
|
|
test('Escape closes the list and returns to the chip', async ({ page }) => {
|
|
await ready(page)
|
|
const chip = page.getByTestId('home-model-chip')
|
|
await chip.click()
|
|
await page.keyboard.press('Escape')
|
|
await expect(page.getByRole('listbox', { name: 'Model' })).toHaveCount(0)
|
|
await expect(chip).toBeFocused()
|
|
})
|
|
})
|
|
|
|
test.describe('Home memory strip', () => {
|
|
test('reads memory use in one line and opens into the loaded models', async ({ page }) => {
|
|
await mockHome(page)
|
|
await ready(page)
|
|
const head = page.locator('.home-strip__head')
|
|
await expect(head).toContainText('2')
|
|
await expect(head).toContainText('models loaded')
|
|
await expect(head).toContainText('8.8 / 24 GB')
|
|
await expect(head).toContainText('RTX 4090')
|
|
await expect(head).toHaveAttribute('aria-expanded', 'false')
|
|
await head.click()
|
|
await expect(head).toHaveAttribute('aria-expanded', 'true')
|
|
await expect(page.locator('.home-loaded__row')).toHaveCount(2)
|
|
// System RAM is listed under the models when the memory shown is GPU.
|
|
await expect(page.locator('.home-ram')).toContainText('18.2 / 64 GB')
|
|
})
|
|
|
|
test('the bar is the memory in use, one solid fill', async ({ page }) => {
|
|
await mockHome(page)
|
|
await ready(page)
|
|
const fill = page.locator('.home-strip__head .home-bar__fill')
|
|
const width = await fill.evaluate(el => el.getBoundingClientRect().width / el.parentElement.getBoundingClientRect().width)
|
|
expect(width).toBeGreaterThan(0.25)
|
|
expect(width).toBeLessThan(0.45)
|
|
const bg = await fill.evaluate(el => getComputedStyle(el).backgroundImage)
|
|
expect(bg).toBe('none')
|
|
})
|
|
|
|
test('stopping one model asks first, then calls the backend', async ({ page }) => {
|
|
await mockHome(page)
|
|
const stops = []
|
|
await page.route('**/backend/shutdown', route => {
|
|
stops.push(route.request().postDataJSON())
|
|
return route.fulfill({ json: { message: 'ok' } })
|
|
})
|
|
await ready(page)
|
|
await page.locator('.home-strip__head').click()
|
|
const row = page.locator('.home-loaded__row', { hasText: 'whisper-large-v3-turbo' })
|
|
await row.hover()
|
|
await row.getByRole('button', { name: /stop/i }).click()
|
|
await expect(page.getByRole('alertdialog')).toContainText('whisper-large-v3-turbo')
|
|
expect(stops).toEqual([])
|
|
await page.getByRole('button', { name: /^Stop whisper-large-v3-turbo$/ }).click()
|
|
await expect.poll(() => stops).toEqual([{ model: 'whisper-large-v3-turbo' }])
|
|
})
|
|
|
|
test('stop all stops every loaded model', async ({ page }) => {
|
|
await mockHome(page)
|
|
const stops = []
|
|
await page.route('**/backend/shutdown', route => {
|
|
stops.push(route.request().postDataJSON().model)
|
|
return route.fulfill({ json: { message: 'ok' } })
|
|
})
|
|
await ready(page)
|
|
await page.locator('.home-strip__head').click()
|
|
await page.getByRole('button', { name: 'Stop all', exact: true }).click()
|
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Stop all' }).click()
|
|
await expect.poll(() => stops.sort()).toEqual(LOADED.map(m => m.id).sort())
|
|
})
|
|
|
|
test('opens by itself while a model is being staged', async ({ page }) => {
|
|
await mockHome(page, { operations: [{ jobID: 'j1', name: 'qwen3-32b-instruct', taskType: 'staging', nodeName: 'gpu-b', progress: 41, isBackend: false }] })
|
|
await ready(page)
|
|
const head = page.locator('.home-strip__head')
|
|
await expect(head).toContainText('Staging qwen3-32b-instruct')
|
|
await expect(head).toContainText('41%')
|
|
await expect(head).toHaveAttribute('aria-expanded', 'true')
|
|
})
|
|
|
|
test('opens by itself after a failure and says what happened in plain words', async ({ page }) => {
|
|
await mockHome(page, { operations: [{ jobID: 'j2', name: 'qwen3-32b-instruct', taskType: 'staging', nodeName: 'gpu-b', progress: 0, error: 'no node has enough free memory', isBackend: false }] })
|
|
await ready(page)
|
|
await expect(page.locator('.home-strip__head')).toHaveAttribute('aria-expanded', 'true')
|
|
const notice = page.getByTestId('home-staging-error')
|
|
await expect(notice).toContainText('qwen3-32b-instruct could not be staged')
|
|
await expect(notice).toContainText('gpu-b')
|
|
// One action; the raw error is behind a disclosure.
|
|
await expect(notice.getByRole('button')).toHaveCount(1)
|
|
await notice.getByText('Details').click()
|
|
await expect(notice).toContainText('no node has enough free memory')
|
|
})
|
|
|
|
test('says it could not reach the server instead of showing a first run', async ({ page }) => {
|
|
await page.route('**/system', route => route.abort())
|
|
await page.route('**/v1/models', route => route.abort())
|
|
await ready(page)
|
|
const notice = page.getByTestId('home-load-error')
|
|
await expect(notice).toContainText('could not load your models')
|
|
await expect(page.getByTestId('home-first-run')).toHaveCount(0)
|
|
// Retry works once the server answers.
|
|
await page.unroute('**/system')
|
|
await page.unroute('**/v1/models')
|
|
await mockHome(page)
|
|
await notice.getByRole('button', { name: 'Try again' }).click()
|
|
await expect(notice).toHaveCount(0)
|
|
await expect(page.locator('.home-strip__head')).toContainText('models loaded')
|
|
})
|
|
|
|
test('shows a skeleton while the first answer is on its way', async ({ page }) => {
|
|
await mockHome(page)
|
|
// Hold the answer until the skeleton was seen: a fixed delay can run out
|
|
// before a slow runner has painted the page.
|
|
let release
|
|
const held = new Promise(resolve => { release = resolve })
|
|
await page.route('**/v1/models', async route => {
|
|
await held
|
|
await route.fulfill({ json: { data: [{ id: 'a' }] } })
|
|
})
|
|
await page.goto('/app', { waitUntil: 'commit' })
|
|
await expect(page.getByTestId('home-strip-skeleton')).toBeVisible()
|
|
release()
|
|
await expect(page.locator('.home-strip__head')).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('Home in a cluster', () => {
|
|
const nodes = [
|
|
{ id: 'a', name: 'gpu-a', status: 'healthy', total_vram: 24 * GB, available_vram: 17 * GB },
|
|
{ id: 'b', name: 'gpu-b', status: 'healthy', total_vram: 48 * GB, available_vram: 21 * GB },
|
|
{ id: 'c', name: 'gpu-c', status: 'healthy', total_vram: 24 * GB, available_vram: 7 * GB },
|
|
]
|
|
|
|
test('the strip says models, nodes and aggregate memory in one line', async ({ page }) => {
|
|
await mockHome(page, { features: { distributed: true, localai_assistant: true }, nodes })
|
|
await ready(page)
|
|
const head = page.locator('.home-strip__head')
|
|
await expect(head).toContainText('2')
|
|
await expect(head).toContainText('models on')
|
|
await expect(head).toContainText('3/3')
|
|
await expect(head).toContainText('nodes')
|
|
await expect(head).toContainText('51 / 96 GB')
|
|
await head.click()
|
|
await expect(page.locator('.home-nodes li')).toHaveCount(3)
|
|
await expect(page.locator('.home-nodes')).toContainText('27 / 48 GB')
|
|
})
|
|
})
|
|
|
|
test.describe('Home resume list', () => {
|
|
const chat = { id: 'c1', title: 'Rewrite the release notes', model: 'qwen3-8b-instruct', kind: 'chat', created: '2026-10-09T10:00:00Z', updated: '2026-10-09T11:00:00Z' }
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.clock.setFixedTime(new Date('2026-10-09T12:00:00Z').getTime())
|
|
await mockHome(page)
|
|
})
|
|
|
|
test('it shows the newest chat the server keeps and opens it', async ({ page }) => {
|
|
let asked = ''
|
|
await page.route('**/api/chats?limit=1&kind=chat', route => { asked = route.request().url(); return route.fulfill({ json: { conversations: [chat], persistent: true, files: true } }) })
|
|
await page.route('**/api/chats/c1', route => route.fulfill({ json: { conversation: chat } }))
|
|
await ready(page)
|
|
const row = page.getByTestId('home-conversation')
|
|
await expect(row).toHaveCount(1)
|
|
await expect(row).toContainText('Rewrite the release notes')
|
|
await expect(row).toContainText('qwen3-8b-instruct')
|
|
expect(asked).toContain('limit=1')
|
|
await row.getByRole('button').first().click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/c1$/)
|
|
})
|
|
|
|
test('a row has no delete button: chats are deleted on the chats page', async ({ page }) => {
|
|
await page.route('**/api/chats?limit=1&kind=chat', route => route.fulfill({ json: { conversations: [chat], persistent: true, files: true } }))
|
|
await ready(page)
|
|
await expect(page.getByTestId('home-conversation')).toHaveCount(1)
|
|
await expect(page.locator('.home-row__delete')).toHaveCount(0)
|
|
})
|
|
|
|
test('with no chats it says where they will appear', async ({ page }) => {
|
|
await page.route('**/api/chats?limit=1&kind=chat', route => route.fulfill({ json: { conversations: [], persistent: true, files: true } }))
|
|
await ready(page)
|
|
await expect(page.locator('.home-empty')).toContainText('Your conversations will wait here')
|
|
})
|
|
})
|
|
|
|
test.describe('Home first run', () => {
|
|
async function mockFirstRun(page) {
|
|
await mockHome(page, { loaded: [], models: [], chatModels: [] })
|
|
await page.route('**/api/models/estimate/*', route => route.fulfill({
|
|
json: { sizeBytes: 2 * GB, sizeDisplay: '2 GB', estimates: { 4096: { vramBytes: 3 * GB, vramDisplay: '3 GB' } } },
|
|
}))
|
|
await page.route('**/api/models?*', route => route.fulfill({
|
|
json: {
|
|
models: [
|
|
{ name: 'tiny-chat', backend: 'llama-cpp', installed: false, tags: ['chat'] },
|
|
{ name: 'small-chat', backend: 'llama-cpp', installed: false, tags: ['chat'] },
|
|
],
|
|
allBackends: [], allTags: [], availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
|
|
},
|
|
}))
|
|
}
|
|
|
|
test('teaches the three steps, with the recommended models inside the second', async ({ page }) => {
|
|
await mockFirstRun(page)
|
|
await ready(page)
|
|
const guide = page.getByTestId('home-first-run')
|
|
// The steps only: the recommended models are a list inside the second step.
|
|
await expect(guide.locator('.home-step')).toHaveCount(3)
|
|
await expect(guide.locator('.home-step[data-state="now"]')).toContainText('Browse the Model Gallery')
|
|
await expect(guide.locator('.home-step').nth(1).locator('.home-starters')).toContainText('tiny-chat')
|
|
await expect(guide.getByRole('button', { name: /browse model gallery/i })).toBeVisible()
|
|
await expect(guide.getByRole('button', { name: /import model/i })).toBeVisible()
|
|
})
|
|
|
|
test('first run offers native model setup without promoting an uninstalled provider', async ({ page }) => {
|
|
await mockFirstRun(page)
|
|
await stubAddonRoutes(page)
|
|
await mockAddons(page, { apps: [] })
|
|
await ready(page)
|
|
await expect(page.getByTestId('home-first-run')).toBeVisible()
|
|
await expect(page.getByTestId('home-first-run-sandbox')).toHaveCount(0)
|
|
await expect(page.getByRole('button', { name: 'Install Sandbox' })).toHaveCount(0)
|
|
await page.reload()
|
|
await expect(page.getByTestId('home-first-run-sandbox')).toHaveCount(0)
|
|
})
|
|
|
|
test('the generic apps command opens the add-on browser without an identity filter', async ({ page }) => {
|
|
await mockFirstRun(page)
|
|
await stubAddonRoutes(page)
|
|
await mockAddons(page, { apps: [] })
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/apps')
|
|
await page.getByTestId('home-slash-apps').click()
|
|
await expect(page).toHaveURL(/\/app\/addons$/)
|
|
})
|
|
|
|
test('the Sandbox step is hidden once the Sandbox is installed', async ({ page }) => {
|
|
await mockFirstRun(page)
|
|
await stubAddonRoutes(page)
|
|
await mockAddons(page, { apps: [app({ id: 'sandbox', name: 'sandbox', title: 'Sandbox', hasUI: false, mainPage: undefined })] })
|
|
const listed = page.waitForResponse(/\/api\/addons\/apps(\?|$)/)
|
|
await ready(page)
|
|
await listed
|
|
await expect(page.getByTestId('home-first-run')).toBeVisible()
|
|
await expect(page.getByTestId('home-first-run-sandbox')).toHaveCount(0)
|
|
})
|
|
|
|
test('the command bar waits for a model and the strip says nothing runs', async ({ page }) => {
|
|
await mockFirstRun(page)
|
|
await ready(page)
|
|
await expect(page.getByTestId('home-model-chip')).toContainText('No model selected')
|
|
await expect(page.getByTestId('home-model-chip')).toBeDisabled()
|
|
await expect(page.locator('.home-textarea')).toHaveAttribute('placeholder', 'Install a model to start chatting')
|
|
await expect(page.locator('.home-send-btn')).toBeDisabled()
|
|
await expect(page.getByTestId('home-strip-idle')).toContainText('No models running')
|
|
})
|
|
|
|
test('slash actions already work before a model is installed', async ({ page }) => {
|
|
await mockFirstRun(page)
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/imp')
|
|
await page.locator('.home-textarea').press('Enter')
|
|
await expect(page).toHaveURL(/\/app\/import-model/)
|
|
})
|
|
|
|
test('install starts from a recommended row', async ({ page }) => {
|
|
await mockFirstRun(page)
|
|
const installs = []
|
|
await page.route('**/api/models/install/*', route => {
|
|
installs.push(new URL(route.request().url()).pathname)
|
|
return route.fulfill({ json: { uuid: 'x', status: 'ok' } })
|
|
})
|
|
await ready(page)
|
|
await page.locator('.home-starters-item', { hasText: 'tiny-chat' }).getByRole('button', { name: /install/i }).click()
|
|
await expect.poll(() => installs.length).toBe(1)
|
|
expect(installs[0]).toContain('tiny-chat')
|
|
})
|
|
|
|
test('a person who is not an admin gets a calm explanation, not install steps', async ({ page }) => {
|
|
await mockFirstRun(page)
|
|
await mockUser(page)
|
|
await page.goto('/app')
|
|
await expect(page.getByRole('heading', { name: 'No Models Available' })).toBeVisible({ timeout: 15_000 })
|
|
await expect(page.getByTestId('home-first-run')).toHaveCount(0)
|
|
await expect(page.locator('.home-textarea')).toHaveCount(0)
|
|
await expect(page.locator('.home-wizard-actions').getByRole('link', { name: /documentation/i })).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('Home send hand-off', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockHome(page)
|
|
})
|
|
|
|
// Home starts the chat: it creates it with the model and sends the typed
|
|
// text as the first message, then opens it. The stubs answer in the shapes of
|
|
// the server: the list and the messages are objects, and a chat is the flat
|
|
// conversation with its waiting messages (core/schema/chats.go).
|
|
async function serverChats(page) {
|
|
const seen = { created: [], sent: [] }
|
|
const conversation = { id: 'n1', title: 'New chat', model: 'qwen3-8b-instruct', kind: 'chat', created: '2026-10-09T10:00:00Z', updated: '2026-10-09T10:00:00Z', running: false }
|
|
await page.route(/\/api\/chats(\?.*)?$/, async route => {
|
|
if (route.request().method() === 'POST') {
|
|
const body = route.request().postDataJSON()
|
|
seen.created.push(body)
|
|
return route.fulfill({ status: 201, json: { ...conversation, model: body.model } })
|
|
}
|
|
return route.fulfill({ json: { conversations: [], persistent: true, files: true } })
|
|
})
|
|
await page.route(/\/api\/chats\/n1\/messages(\?.*)?$/, route => {
|
|
if (route.request().method() === 'POST') {
|
|
seen.sent.push(route.request().postDataJSON())
|
|
return route.fulfill({ json: { started: true } })
|
|
}
|
|
return route.fulfill({ json: { messages: [] } })
|
|
})
|
|
await page.route('**/api/chats/n1/events', route => route.abort())
|
|
await page.route('**/api/chats/n1', route => route.fulfill({ json: { ...conversation, pending: [] } }))
|
|
return seen
|
|
}
|
|
|
|
test('Send opens the chats page and starts the chat with the typed text and the model', async ({ page }) => {
|
|
const seen = await serverChats(page)
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('Summarise the release notes')
|
|
await page.locator('.home-send-btn').click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
await expect.poll(() => seen.sent.length).toBe(1)
|
|
// Give a second, wrong send time to arrive before the counts are read.
|
|
await page.waitForTimeout(1500)
|
|
expect(seen.created).toHaveLength(1)
|
|
expect(seen.created[0].model).toBe('qwen3-8b-instruct')
|
|
expect(seen.sent).toEqual([{ text: 'Summarise the release notes' }])
|
|
})
|
|
|
|
test('Enter sends and Shift+Enter adds a line', async ({ page }) => {
|
|
const seen = await serverChats(page)
|
|
await ready(page)
|
|
const box = page.locator('.home-textarea')
|
|
await box.fill('line one')
|
|
await box.press('Shift+Enter')
|
|
await box.type('line two')
|
|
await expect(box).toHaveValue('line one\nline two')
|
|
await box.press('Enter')
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
await expect.poll(() => seen.sent.length).toBe(1)
|
|
await page.waitForTimeout(1500)
|
|
expect(seen.created).toHaveLength(1)
|
|
expect(seen.sent).toEqual([{ text: 'line one\nline two' }])
|
|
})
|
|
|
|
test('Back from the new chat sends nothing again', async ({ page }) => {
|
|
const seen = await serverChats(page)
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('Only once')
|
|
await page.locator('.home-send-btn').click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
await expect.poll(() => seen.sent.length).toBe(1)
|
|
// The address changes first and the chat page draws a frame later; Back
|
|
// is from the new chat only once it is there.
|
|
await expect(page.getByTestId('chats-page')).toBeVisible()
|
|
|
|
// Back is Home again, with the draft gone: nothing is sent twice.
|
|
await page.goBack()
|
|
await expect(page).toHaveURL(/\/app$/)
|
|
await expect(page.locator('.home-textarea')).toHaveValue('')
|
|
await page.waitForTimeout(1500)
|
|
expect(seen.created).toHaveLength(1)
|
|
expect(seen.sent).toHaveLength(1)
|
|
|
|
await page.goForward()
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
await page.waitForTimeout(500)
|
|
expect(seen.sent).toHaveLength(1)
|
|
})
|
|
|
|
test('Send stays off until there is text', async ({ page }) => {
|
|
await ready(page)
|
|
await expect(page.locator('.home-send-btn')).toBeDisabled()
|
|
await page.locator('.home-textarea').fill('hi')
|
|
await expect(page.locator('.home-send-btn')).toBeEnabled()
|
|
})
|
|
|
|
test('the old chat address opens Home and keeps the model as a hint', async ({ page }) => {
|
|
await page.route('**/api/chats?limit=1&kind=chat', route => route.fulfill({ json: { conversations: [], persistent: true, files: true } }))
|
|
await page.goto('/app/chat/qwen3-8b-instruct')
|
|
await expect(page).toHaveURL(/\/app\?model=qwen3-8b-instruct$/)
|
|
await expect(page.getByTestId('home-model-chip')).toContainText('qwen3-8b-instruct')
|
|
})
|
|
})
|
|
|
|
test.describe('Home on a phone', () => {
|
|
test.use({ viewport: { width: 390, height: 844 } })
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockHome(page)
|
|
})
|
|
|
|
test('the slash list stays inside the screen', async ({ page }) => {
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
const box = await page.locator('.home-slash').boundingBox()
|
|
expect(box.x).toBeGreaterThanOrEqual(0)
|
|
expect(box.x + box.width).toBeLessThanOrEqual(390)
|
|
})
|
|
|
|
test('first run fits too', async ({ page }) => {
|
|
await page.unroute('**/system')
|
|
await page.unroute('**/v1/models')
|
|
await page.route('**/system', route => route.fulfill({ json: { backends: [], loaded_models: [] } }))
|
|
await page.route('**/v1/models', route => route.fulfill({ json: { data: [] } }))
|
|
await ready(page)
|
|
await expect(page.getByTestId('home-first-run')).toBeVisible()
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
|
expect(overflow).toBeLessThanOrEqual(0)
|
|
})
|
|
})
|
|
|
|
test.describe('Home reduced motion', () => {
|
|
test('rows and menus do not animate', async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: 'reduce' })
|
|
await mockHome(page)
|
|
await ready(page)
|
|
await page.locator('.home-textarea').fill('/')
|
|
const name = await page.locator('.home-slash').evaluate(el => getComputedStyle(el).animationName)
|
|
expect(name).toBe('none')
|
|
})
|
|
})
|