mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-07 04:53:24 -05:00
Chromium reported 175.99998 px before focus and 176 px after, so the exact toBe check failed in about half of the runs. Allow half a pixel. Assisted-by: Claude:claude-sonnet-5-5 playwright
272 lines
14 KiB
JavaScript
272 lines
14 KiB
JavaScript
import { test, expect } from './coverage-fixtures.js'
|
|
import { mockHome } from './home-fixtures.js'
|
|
|
|
// The composer of Home is the chat composer: files, the saved agent, the Tools
|
|
// and Skills menus and the draft all work before the chat exists. Send creates
|
|
// the chat with the model, the agent and the drafted switches, sends the first
|
|
// message with its files and opens the chat. The API is stubbed in the shapes
|
|
// of the server (core/schema/chats.go).
|
|
|
|
const CONV = { id: 'n1', title: 'New chat', model: 'qwen3-8b-instruct', kind: 'chat', created: '2026-10-30T10:00:00Z', updated: '2026-10-30T10:00:00Z', running: false }
|
|
|
|
async function server(page, { failSend = false, agents = [] } = {}) {
|
|
const seen = { created: [], sent: [], deleted: [] }
|
|
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: { ...CONV, model: body.model || CONV.model } })
|
|
}
|
|
return route.fulfill({ json: { conversations: [], persistent: true, files: true } })
|
|
})
|
|
await page.route(/\/api\/chats\/n1\/messages(\?.*)?$/, route => {
|
|
if (route.request().method() === 'POST') {
|
|
const req = route.request()
|
|
seen.sent.push({ type: req.headers()['content-type'] || '', body: req.postDataBuffer()?.toString('utf8') || '' })
|
|
if (failSend) return route.fulfill({ status: 413, json: { error: { message: 'the file is too large' } } })
|
|
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 => {
|
|
if (route.request().method() === 'DELETE') { seen.deleted.push('n1'); return route.fulfill({ json: {} }) }
|
|
return route.fulfill({ json: { ...CONV, pending: [] } })
|
|
})
|
|
// What a new chat could switch: one group of built-in tools, one add-on and
|
|
// two skills, every switch in its default state.
|
|
await page.route(/\/api\/chats\/draft\/tools(\?.*)?$/, route => route.fulfill({ json: {
|
|
builtins: [{ group: 'web', tools: ['web_search', 'web_fetch'], off: false, usable: true, provider: 'builtin', providers: {} }],
|
|
addons: [{ id: 'office', tools: 4, off: false }], servers: [], connectors: [], off: [],
|
|
} }))
|
|
await page.route(/\/api\/chats\/draft\/addons(\?.*)?$/, route => route.fulfill({ json: { addons: [{ id: 'office', tools: 4, off: false }], commands: 'none' } }))
|
|
await page.route(/\/api\/chats\/skills(\?.*)?$/, route => route.fulfill({ json: { skills: [
|
|
{ id: 'builtin:pdf', name: 'pdf', description: 'Read and write PDF files', layer: 'builtin', enabled: true },
|
|
{ id: 'personal:notes', name: 'notes', description: 'Keep notes', layer: 'personal', enabled: true },
|
|
] } }))
|
|
await page.route('**/api/chats/agents', route => route.fulfill({ json: { agents } }))
|
|
return seen
|
|
}
|
|
|
|
async function ready(page) {
|
|
await page.goto('/app')
|
|
await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 })
|
|
await expect(page.getByTestId('home-model-chip')).toContainText('qwen3-8b-instruct')
|
|
}
|
|
|
|
test.describe('Home composer', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockHome(page)
|
|
})
|
|
|
|
test('focus draws one indicator, on the card, and the message box draws none', async ({ page }) => {
|
|
await server(page)
|
|
await ready(page)
|
|
const card = page.getByTestId('home-composer')
|
|
const box = page.getByTestId('home-input')
|
|
const before = (await card.boundingBox()).height
|
|
await box.click()
|
|
await expect(card).toHaveCSS('box-shadow', /inset/)
|
|
await expect(box).toHaveCSS('outline-style', 'none')
|
|
await expect(box).toHaveCSS('box-shadow', 'none')
|
|
// Layout rounds to 1/64 px, so compare with a sub-pixel tolerance.
|
|
expect(Math.abs((await card.boundingBox()).height - before)).toBeLessThan(0.5)
|
|
// the control that takes keyboard focus next keeps its own ring
|
|
await page.keyboard.press('Shift+Tab')
|
|
await expect(card.locator(':focus-visible')).toHaveCSS('outline-style', 'solid')
|
|
})
|
|
|
|
test('a file attached on Home arrives with the first message of the new chat', async ({ page }) => {
|
|
const seen = await server(page)
|
|
await ready(page)
|
|
await page.getByTestId('home-input').fill('What is in this file?')
|
|
await page.getByTestId('chat-file-input').setInputFiles({ name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('remember the milk') })
|
|
await expect(page.getByTestId('home-composer').getByText('notes.txt')).toBeVisible()
|
|
await page.getByTestId('home-send').click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
expect(seen.created).toHaveLength(1)
|
|
expect(seen.sent).toHaveLength(1)
|
|
expect(seen.sent[0].type).toContain('multipart/form-data')
|
|
expect(seen.sent[0].body).toContain('What is in this file?')
|
|
expect(seen.sent[0].body).toContain('filename="notes.txt"')
|
|
expect(seen.sent[0].body).toContain('remember the milk')
|
|
})
|
|
|
|
test('a pasted image is attached, not typed', async ({ page }) => {
|
|
await server(page)
|
|
await ready(page)
|
|
const box = page.getByTestId('home-input')
|
|
await box.focus()
|
|
await box.evaluate((el) => {
|
|
const dt = new DataTransfer()
|
|
dt.items.add(new File([new Uint8Array([137, 80, 78, 71])], 'image.png', { type: 'image/png' }))
|
|
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))
|
|
})
|
|
await expect(page.getByTestId('home-composer').getByText(/^pasted-image-\d+-1\.png$/)).toBeVisible()
|
|
await expect(box).toHaveValue('')
|
|
})
|
|
|
|
test('tools and skills switched off on Home are stored with the new chat', async ({ page }) => {
|
|
const seen = await server(page)
|
|
await ready(page)
|
|
await page.getByTestId('chat-tools-button').click()
|
|
const tools = page.getByTestId('chat-tools-menu')
|
|
await expect(tools).toContainText('A new chat starts with these switches.')
|
|
await tools.getByRole('switch', { name: 'office', exact: true }).click()
|
|
await expect(tools.getByRole('switch', { name: 'office', exact: true })).toHaveAttribute('aria-checked', 'false')
|
|
await page.keyboard.press('Escape')
|
|
|
|
await page.getByTestId('chat-skills-button').click()
|
|
await page.getByRole('switch', { name: 'pdf', exact: true }).click()
|
|
await expect(page.getByTestId('chat-skills-summary')).toHaveText('1 of 2 on')
|
|
await page.keyboard.press('Escape')
|
|
|
|
await page.getByTestId('home-input').fill('Plan my week')
|
|
await page.getByTestId('home-input').press('Enter')
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
expect(seen.created).toHaveLength(1)
|
|
expect(seen.created[0].model).toBe('qwen3-8b-instruct')
|
|
expect(seen.created[0].switches).toEqual({ addons_off: ['office'], skills_off: ['builtin:pdf'] })
|
|
})
|
|
|
|
test('toggling the Sandbox add-on in a draft keeps the commands notice away and reads the skills again', async ({ page }) => {
|
|
const seen = await server(page)
|
|
const skillReads = []
|
|
await page.route(/\/api\/chats\/draft\/addons(\?.*)?$/, route => route.fulfill({ json: {
|
|
addons: [{ id: 'sandbox', tools: 7, off: false }], commands: 'available', commands_provider: 'sandbox',
|
|
execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: 'draft' },
|
|
} }))
|
|
await page.route(/\/api\/chats\/skills(\?.*)?$/, route => { skillReads.push(route.request().url()); return route.fulfill({ json: { skills: [
|
|
{ id: 'addon:docx', name: 'docx', description: 'Word files', layer: 'addon', enabled: true },
|
|
] } }) })
|
|
await ready(page)
|
|
await page.getByTestId('chat-tools-button').click()
|
|
await expect(page.getByTestId('chat-commands-notice')).toHaveCount(0)
|
|
const reads = skillReads.length
|
|
await page.getByRole('switch', { name: 'sandbox' }).uncheck()
|
|
await page.getByRole('switch', { name: 'sandbox' }).check()
|
|
await expect(page.getByTestId('chat-commands-notice')).toHaveCount(0)
|
|
await expect(page.getByTestId('chat-commands-chip')).toHaveCount(0)
|
|
await expect.poll(() => skillReads.length).toBeGreaterThan(reads)
|
|
expect(seen.created).toHaveLength(0)
|
|
})
|
|
|
|
test('a draft with the shell group switched off asks the server and shows the switched off notice', async ({ page }) => {
|
|
await server(page)
|
|
const asked = []
|
|
await page.route(/\/api\/chats\/draft\/tools(\?.*)?$/, route => route.fulfill({ json: {
|
|
builtins: [{ group: 'shell', tools: ['bash'], off: false, usable: true, provider: 'sandbox', providers: { bash: 'sandbox' } }],
|
|
addons: [{ id: 'sandbox', tools: 7, off: false }], servers: [], connectors: [], off: [],
|
|
} }))
|
|
await page.route(/\/api\/chats\/draft\/addons(\?.*)?$/, route => {
|
|
const url = new URL(route.request().url())
|
|
asked.push(url.searchParams.get('builtins_off') || '')
|
|
const off = url.searchParams.get('builtins_off') === 'shell'
|
|
return route.fulfill({ json: { addons: [{ id: 'sandbox', tools: 7, off: false }], commands: off ? 'switched_off' : 'available', commands_provider: 'sandbox',
|
|
execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: 'draft' } } })
|
|
})
|
|
await ready(page)
|
|
await page.getByTestId('chat-tools-button').click()
|
|
await expect(page.getByTestId('chat-commands-notice')).toHaveCount(0)
|
|
await page.getByTestId('chat-tools-menu').getByRole('switch', { name: 'Run commands', exact: true }).click()
|
|
await expect(page.getByTestId('chat-commands-notice')).toContainText('Running commands is switched off for this chat.')
|
|
expect(asked).toContain('shell')
|
|
await page.getByTestId('chat-tools-menu').getByRole('switch', { name: 'Run commands', exact: true }).click()
|
|
await expect(page.getByTestId('chat-commands-notice')).toHaveCount(0)
|
|
})
|
|
|
|
test('the draft lists the groups of built-in tools, and one switched off goes with the new chat', async ({ page }) => {
|
|
const seen = await server(page)
|
|
await page.route(/\/api\/chats\/draft\/tools(\?.*)?$/, route => route.fulfill({ json: {
|
|
builtins: [
|
|
{ group: 'web', tools: ['web_search', 'web_fetch'], off: false, usable: true, provider: 'builtin', providers: {} },
|
|
{ group: 'files', tools: ['list_files', 'read_file'], off: false, usable: true, provider: 'builtin', providers: {} },
|
|
{ group: 'media', tools: ['describe_image', 'transcribe_audio'], off: false, usable: true, provider: 'builtin', providers: {} },
|
|
{ group: 'subagents', tools: ['spawn_agent'], off: false, usable: true, provider: 'builtin', providers: {} },
|
|
],
|
|
addons: [], servers: [], connectors: [], off: [],
|
|
} }))
|
|
await ready(page)
|
|
await page.getByTestId('chat-tools-button').click()
|
|
const tools = page.getByTestId('chat-tools-menu')
|
|
for (const name of ['Your files', 'Look things up online', 'Images and audio', 'Sub-agents']) {
|
|
await expect(tools.getByRole('switch', { name, exact: true })).toHaveAttribute('aria-checked', 'true')
|
|
}
|
|
await tools.getByRole('switch', { name: 'Your files', exact: true }).click()
|
|
await expect(tools.getByRole('switch', { name: 'Your files', exact: true })).toHaveAttribute('aria-checked', 'false')
|
|
await page.keyboard.press('Escape')
|
|
await page.getByTestId('home-input').fill('Plan my week')
|
|
await page.getByTestId('home-input').press('Enter')
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
expect(seen.created[0].switches).toEqual({ builtins_off: ['files'] })
|
|
})
|
|
|
|
test('a chat with nothing switched sends no switches', async ({ page }) => {
|
|
const seen = await server(page)
|
|
await ready(page)
|
|
await page.getByTestId('home-input').fill('hello')
|
|
await page.getByTestId('home-send').click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
expect(seen.created[0]).not.toHaveProperty('switches')
|
|
})
|
|
|
|
test('the saved agent chosen on Home starts the chat', async ({ page }) => {
|
|
const seen = await server(page, { agents: [{ id: 'a1', name: 'Reviewer', model: '' }] })
|
|
await ready(page)
|
|
await page.getByTestId('chat-agent-picker').getByRole('button').click()
|
|
// Choosing closes the menu.
|
|
await page.getByRole('radio', { name: 'Reviewer' }).click()
|
|
await expect(page.getByTestId('chat-agent-picker').getByRole('button')).toContainText('Reviewer')
|
|
await page.getByTestId('home-input').fill('Review this')
|
|
await page.getByTestId('home-send').click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
expect(seen.created[0].agent_id).toBe('a1')
|
|
})
|
|
|
|
test('the slash menu of Home opens in the rich composer', async ({ page }) => {
|
|
await server(page)
|
|
await ready(page)
|
|
await page.getByTestId('home-input').fill('/')
|
|
await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible()
|
|
await expect(page.getByTestId('chat-tools-button')).toBeVisible()
|
|
})
|
|
|
|
test('the draft of Home survives a reload and is gone once sent', async ({ page }) => {
|
|
await server(page)
|
|
await ready(page)
|
|
await page.getByTestId('home-input').fill('half a thought')
|
|
// The draft is written a moment after the last key.
|
|
await page.waitForTimeout(500)
|
|
await page.reload()
|
|
await expect(page.getByTestId('home-input')).toHaveValue('half a thought')
|
|
await page.getByTestId('home-send').click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
await page.goto('/app')
|
|
await expect(page.getByTestId('home-input')).toHaveValue('')
|
|
})
|
|
|
|
test('a message that cannot go removes the new chat and keeps the draft', async ({ page }) => {
|
|
const seen = await server(page, { failSend: true })
|
|
await ready(page)
|
|
await page.getByTestId('home-input').fill('try this')
|
|
await page.getByTestId('chat-file-input').setInputFiles({ name: 'big.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(16) })
|
|
await page.getByTestId('home-send').click()
|
|
await expect(page.getByText('the file is too large')).toBeVisible()
|
|
await expect(page).toHaveURL(/\/app$/)
|
|
await expect.poll(() => seen.deleted.length).toBe(1)
|
|
await expect(page.getByTestId('home-input')).toHaveValue('try this')
|
|
await expect(page.getByTestId('home-composer').getByText('big.bin')).toBeVisible()
|
|
})
|
|
|
|
test('a file alone can be sent', async ({ page }) => {
|
|
const seen = await server(page)
|
|
await ready(page)
|
|
await expect(page.getByTestId('home-send')).toBeDisabled()
|
|
await page.getByTestId('chat-file-input').setInputFiles({ name: 'q1.csv', mimeType: 'text/csv', buffer: Buffer.from('a,b') })
|
|
await expect(page.getByTestId('home-send')).toBeEnabled()
|
|
await page.getByTestId('home-send').click()
|
|
await expect(page).toHaveURL(/\/app\/chats\/n1$/)
|
|
expect(seen.sent[0].body).toContain('filename="q1.csv"')
|
|
})
|
|
})
|