From 45f7ff0b1c0c1560eb51dd334841a5e6cf338a2f Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 11:40:12 +0000 Subject: [PATCH] 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 --- core/http/react-ui/e2e/chats-origin.js | 60 ++++++++ core/http/react-ui/e2e/chats-origin.spec.js | 158 ++++++++++++++++++++ core/http/react-ui/e2e/home-console.spec.js | 9 +- 3 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 core/http/react-ui/e2e/chats-origin.js create mode 100644 core/http/react-ui/e2e/chats-origin.spec.js diff --git a/core/http/react-ui/e2e/chats-origin.js b/core/http/react-ui/e2e/chats-origin.js new file mode 100644 index 0000000000..a62adb1d69 --- /dev/null +++ b/core/http/react-ui/e2e/chats-origin.js @@ -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 } +} diff --git a/core/http/react-ui/e2e/chats-origin.spec.js b/core/http/react-ui/e2e/chats-origin.spec.js new file mode 100644 index 0000000000..6104a40f76 --- /dev/null +++ b/core/http/react-ui/e2e/chats-origin.spec.js @@ -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) + }) + }) +}) diff --git a/core/http/react-ui/e2e/home-console.spec.js b/core/http/react-ui/e2e/home-console.spec.js index 79fe177ed8..0b8ae5fab5 100644 --- a/core/http/react-ui/e2e/home-console.spec.js +++ b/core/http/react-ui/e2e/home-console.spec.js @@ -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')