test(ui): cover the separate list of chats that no person started

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:41:13 +00:00
1 parent dae3d34a21
commit 45f7ff0b1c
3 files changed
+223 -4

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
// Stubs for the specs about chats that a person did not start: a list API that
// honours the origin filter, the lists that name the origins and an empty inbox.
import { mockChat } from './chat-fixtures.js'
const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString()
export const PERSON = [
{ id: 'p1', title: 'Plan the week', model: 'qwen3-8b', kind: 'chat', created: minutesAgo(300), updated: minutesAgo(120), running: false },
{ id: 'p2', title: 'Compare the quotes', model: 'qwen3-8b', kind: 'chat', created: minutesAgo(900), updated: minutesAgo(600), running: false },
]
export const AUTOMATED = [
{ id: 'b1', title: 'Chat App · alice', model: 'qwen3-8b', kind: 'chat', origin: 'connector', origin_id: 'bind1', created: minutesAgo(50), updated: minutesAgo(2), running: true, last_turn: 'done' },
{ id: 'b2', title: 'Chat App · bob', model: 'qwen3-8b', kind: 'chat', origin: 'connector', origin_id: 'bind1', created: minutesAgo(80), updated: minutesAgo(30), running: false, last_turn: 'paused' },
{ id: 'b3', title: 'Chat App · carol', model: 'qwen3-8b', kind: 'chat', origin: 'connector', origin_id: 'bind2', created: minutesAgo(700), updated: minutesAgo(400), running: false, last_turn: 'done' },
{ id: 't1', title: 'Morning digest', model: 'qwen3-8b', kind: 'chat', origin: 'trigger', origin_id: 'trig1', created: minutesAgo(200), updated: minutesAgo(180), running: false, last_turn: 'done' },
{ id: 'j1', title: 'Nightly report', model: 'qwen3-8b', kind: 'chat', origin: 'job', origin_id: 'nightly', created: minutesAgo(1000), updated: minutesAgo(900), running: false, last_turn: 'done' },
{ id: 'f1', title: 'From a future origin', model: 'qwen3-8b', kind: 'chat', origin: 'future', origin_id: 'x1', created: minutesAgo(1100), updated: minutesAgo(1000), running: false, last_turn: 'done' },
]
const CONNECTORS = [
{ id: 'bind1', name: 'Team assistant', kind: 'chatapp', addon: 'chatapp', enabled: true },
{ id: 'bind2', name: '', kind: 'chatapp', addon: 'chatapp', enabled: true },
]
const KINDS = [{ addon: 'chatapp', kind: 'chatapp', title: 'Chat App', page: 'bot' }]
const TRIGGERS = [{ id: 'trig1', name: 'Morning digest', kind: 'schedule', enabled: true }]
// Stub the chat pages with a list that filters by the origin parameter the way
// the server does. `lists` records the origin of every list request.
export async function mockOriginChats(page, { person = PERSON, automated = AUTOMATED, notifications = [] } = {}) {
const lists = []
await mockChat(page)
await page.route(/\/api\/chats(\?.*)?$/, (route) => {
const params = new URL(route.request().url()).searchParams
const origin = params.get('origin') || ''
const limit = Number(params.get('limit')) || 50
lists.push(origin)
const all = [...person, ...automated]
const conversations = origin === 'person' ? person
: origin === 'automated' ? automated
: origin ? automated.filter(c => c.origin === origin) : all
route.fulfill({ json: { conversations: conversations.slice(0, limit), persistent: true, files: true } })
})
await page.route(/\/api\/chats\/connectors(\?.*)?$/, route => route.fulfill({ json: { connectors: CONNECTORS } }))
await page.route('**/api/chats/connectors/kinds', route => route.fulfill({ json: { kinds: KINDS } }))
await page.route(/\/api\/chats\/triggers(\?.*)?$/, route => route.fulfill({ json: { triggers: TRIGGERS } }))
await page.route(/\/api\/chats\/notifications(\?.*)?$/, route => route.fulfill({ json: { notifications, unread: notifications.length, total: notifications.length } }))
await page.route('**/api/chats/notifications/events', route => route.abort())
await page.route(/\/api\/chats\/search(\?.*)?$/, (route) => {
const q = (new URL(route.request().url()).searchParams.get('q') || '').toLowerCase()
const hits = [...person, ...automated].filter(c => c.title.toLowerCase().includes(q)).map(c => ({ chat: c, in_title: true }))
route.fulfill({ json: { hits } })
})
for (const c of [...person, ...automated]) {
await page.route(`**/api/chats/${c.id}/events`, route => route.abort())
await page.route(new RegExp(`/api/chats/${c.id}/messages(\\?.*)?$`), route => route.fulfill({ json: { messages: [{ seq: 1, turn: 1, role: 'user', text: 'hello', at: c.created }] } }))
await page.route(new RegExp(`/api/chats/${c.id}$`), route => route.fulfill({ json: { ...c, pending: [] } }))
}
return { lists }
}
+158
View File
@@ -0,0 +1,158 @@
import { test, expect } from './coverage-fixtures.js'
import { mockOriginChats } from './chats-origin.js'
// Chats that a trigger, a connector, a job or a page started stay out of the
// lists of a person's own chats and have a tab of their own in All chats. The
// API is stubbed with a list that honours the origin parameter.
test.describe('Chats that no person started', () => {
test('the sidebar and All chats list only the chats a person started by default', async ({ page }) => {
const { lists } = await mockOriginChats(page)
await page.goto('/app/chats/all')
const list = page.getByTestId('all-chats-list')
await expect(list.getByTestId('chats-row')).toHaveCount(2)
await expect(list.getByText('Chat App · alice')).toHaveCount(0)
const recent = page.getByTestId('sidebar-chats').getByTestId('sidebar-chat')
await expect(recent).toHaveCount(2)
await expect(page.getByTestId('sidebar-chats').getByText('Chat App · alice')).toHaveCount(0)
expect(lists).toContain('person')
expect(lists).toContain('automated')
})
test('the second tab groups them by kind and by origin, with counts, status and badges', async ({ page }) => {
await mockOriginChats(page)
await page.goto('/app/chats/all')
await expect(page.getByTestId('all-chats-count-mine')).toHaveText('2')
await expect(page.getByTestId('all-chats-count-automated')).toHaveText('6')
await page.getByTestId('all-chats-tab-automated').click()
const panel = page.getByTestId('automated-chats')
await expect(panel).toBeVisible()
// Kinds keep a fixed order and the unknown one comes last.
await expect(panel.locator('.ac-kind__title')).toHaveText([/Triggers/, /Connectors/, /Jobs/, /Other/])
const connector = panel.getByTestId('automated-kind-connector')
const groups = connector.getByTestId('automated-group')
await expect(groups).toHaveCount(2)
// Titles come from the binding's name, then from the title of its kind.
await expect(groups.nth(0).getByTestId('automated-group-title')).toHaveText('Team assistant')
await expect(groups.nth(1).getByTestId('automated-group-title')).toHaveText('Chat App')
await expect(groups.nth(0).getByTestId('automated-count')).toHaveText('Chats: 2')
await expect(groups.nth(0).getByTestId('automated-origin-badge')).toHaveText('Connector')
await expect(groups.nth(0).getByTestId('automated-running')).toBeVisible()
await expect(groups.nth(0).getByTestId('automated-waiting')).toBeVisible()
await expect(groups.nth(1).getByTestId('automated-waiting')).toHaveCount(0)
await expect(panel.getByTestId('automated-kind-trigger').getByTestId('automated-group-title')).toHaveText('Morning digest')
await expect(panel.getByTestId('automated-kind-future').getByTestId('automated-origin-badge')).toHaveText('Automated')
// The origin links to its page.
await expect(groups.nth(0).getByTestId('automated-origin-link')).toHaveAttribute('href', '/app/addons/chatapp/bot')
await expect(panel.getByTestId('automated-kind-trigger').getByTestId('automated-origin-link')).toHaveAttribute('href', '/app/triggers/trig1')
// A chat of the group opens as usual.
await groups.nth(0).getByTestId('automated-chat').filter({ hasText: 'Chat App · bob' }).locator('.ac-row__open').click()
await expect(page).toHaveURL(/\/app\/chats\/b2$/)
})
test('the tab that was open is remembered in this browser', async ({ page }) => {
await mockOriginChats(page)
await page.goto('/app/chats/all')
await page.getByTestId('all-chats-tab-automated').click()
await page.reload()
await expect(page.getByTestId('automated-chats')).toBeVisible()
await expect(page.getByTestId('all-chats-tab-automated')).toHaveAttribute('aria-selected', 'true')
await page.getByTestId('all-chats-tab-mine').click()
await page.reload()
await expect(page.getByTestId('all-chats-list')).toBeVisible()
})
test('a browser without storage still shows the first tab', async ({ page }) => {
await mockOriginChats(page)
await page.addInitScript(() => {
// Only the key of the tab throws, as it would in a window without site data.
const get = Storage.prototype.getItem
const set = Storage.prototype.setItem
Storage.prototype.getItem = function (k) { if (k === 'localai_all_chats_tab') throw new Error('denied'); return get.call(this, k) }
Storage.prototype.setItem = function (k, v) { if (k === 'localai_all_chats_tab') throw new Error('denied'); return set.call(this, k, v) }
})
await page.goto('/app/chats/all')
await expect(page.getByTestId('all-chats-list')).toBeVisible()
await page.getByTestId('all-chats-tab-automated').click()
await expect(page.getByTestId('automated-chats')).toBeVisible()
})
test('without such chats there are no tabs', async ({ page }) => {
await mockOriginChats(page, { automated: [] })
await page.goto('/app/chats/all')
await expect(page.getByTestId('all-chats-list')).toBeVisible()
await expect(page.getByTestId('all-chats-tabs')).toHaveCount(0)
await expect(page.getByTestId('sidebar-automated-badge')).toHaveCount(0)
})
test('the All chats entry shows how many of them wait for the person', async ({ page }) => {
await mockOriginChats(page, {
notifications: [{ id: 1, kind: 'needs_attention', status: 'ok', title: 'x', body: '', link: '/app/chats/t1', read: false, created: new Date().toISOString() }],
})
await page.goto('/app')
// b2 paused at its step limit, t1 has an unread item that asks for the person.
const badge = page.getByTestId('sidebar-automated-badge')
await expect(badge).toHaveText('2')
await expect(badge).toHaveAttribute('aria-label', 'Automated chats waiting for you: 2')
await page.getByTestId('sidebar-all-chats').click()
await expect(page.getByTestId('all-chats-waiting')).toHaveText('2')
})
test('only a running turn shows a quiet dot instead of a number', async ({ page }) => {
await mockOriginChats(page, { automated: [{ id: 'b1', title: 'Chat App · alice', model: 'qwen3-8b', kind: 'chat', origin: 'connector', origin_id: 'bind1', created: new Date().toISOString(), updated: new Date().toISOString(), running: true }] })
await page.goto('/app')
await expect(page.getByTestId('sidebar-automated-running')).toBeVisible()
await expect(page.getByTestId('sidebar-automated-badge')).toHaveCount(0)
})
test('Home does not offer them under Jump back in', async ({ page }) => {
const { lists } = await mockOriginChats(page)
await page.goto('/app')
await expect(page.getByTestId('home-conversation')).toHaveCount(1)
await expect(page.getByTestId('home-conversation').first()).toContainText('Plan the week')
expect(lists).toContain('person')
})
test('Ctrl+K in a transcript finds every chat and marks the ones a person did not start', async ({ page }) => {
await mockOriginChats(page)
await page.goto('/app/chats/p1')
await page.getByTestId('chats-trigger').click()
const menu = page.getByTestId('chats-menu')
await expect(menu.getByTestId('chats-row')).toHaveCount(8)
await menu.getByRole('combobox').fill('alice')
const row = menu.getByTestId('chats-row')
await expect(row).toHaveCount(1)
await expect(row.getByTestId('chats-origin-badge')).toHaveText('Team assistant')
await menu.getByRole('combobox').fill('Plan')
await expect(menu.getByTestId('chats-row').getByTestId('chats-origin-badge')).toHaveCount(0)
})
test('a chat a connector started opens with a banner that names the origin and links to it', async ({ page }) => {
await mockOriginChats(page)
await page.goto('/app/chats/b1')
const banner = page.getByTestId('chat-origin-banner')
await expect(banner).toContainText('This conversation was started by Team assistant.')
await expect(page.getByTestId('chat-origin-link')).toHaveAttribute('href', '/app/addons/chatapp/bot')
await page.goto('/app/chats/f1')
await expect(page.getByTestId('chat-origin-banner')).toContainText('started by something else')
await expect(page.getByTestId('chat-origin-link')).toHaveCount(0)
await page.goto('/app/chats/p1')
await expect(page.getByTestId('chat-input')).toBeVisible()
await expect(page.getByTestId('chat-origin-banner')).toHaveCount(0)
})
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('the tabs and the groups fit the width', async ({ page }) => {
await mockOriginChats(page)
await page.goto('/app/chats/all')
await page.getByTestId('all-chats-tab-automated').click()
await expect(page.getByTestId('automated-chats')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
const box = await page.getByTestId('all-chats-tabs').boundingBox()
expect(box.x + box.width).toBeLessThanOrEqual(390)
})
})
})
+5 -4
View File
@@ -401,7 +401,7 @@ test.describe('Home resume list', () => {
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?limit=1&kind=chat&origin=person', 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')
@@ -409,19 +409,20 @@ test.describe('Home resume list', () => {
await expect(row).toContainText('Rewrite the release notes')
await expect(row).toContainText('qwen3-8b-instruct')
expect(asked).toContain('limit=1')
expect(asked).toContain('origin=person')
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 page.route('**/api/chats?limit=1&kind=chat&origin=person', 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 page.route('**/api/chats?limit=1&kind=chat&origin=person', route => route.fulfill({ json: { conversations: [], persistent: true, files: true } }))
await ready(page)
await expect(page.locator('.home-empty')).toContainText('Your conversations will wait here')
})
@@ -626,7 +627,7 @@ test.describe('Home send hand-off', () => {
})
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.route('**/api/chats?limit=1&kind=chat&origin=person', 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')