import { expect, test, type Page } from '@playwright/test' import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures' const inputPlaceholder = 'Type a message... (Enter to send, Shift+Enter for new line)' const sampleSession = { id: 'sess-1', title: 'First session', source: 'cli', created_at: 1760000000000, updated_at: 1760000000000, message_count: 2, input_tokens: 0, output_tokens: 0, reasoning_tokens: 0, } async function sendChatMessage(page: Page, message: string) { const input = page.getByPlaceholder(inputPlaceholder) await expect(input).toBeVisible() await input.fill(message) await page.getByRole('button', { name: 'Send' }).click() } async function waitForRunSessionId(page: Page, index = 0) { const handle = await page.waitForFunction((runIndex) => { const state = (window as any).__PW_CHAT_SOCKET__ const runs = state?.emitted?.filter((item: any) => item.event === 'run') || [] const run = runs[runIndex] return run ? { sessionId: run.payload?.session_id } : null }, index) return handle.jsonValue() as Promise } // Covers the Phase 006 manual checklist rows for streaming, markdown rendering, // and the run lifecycle, so a rendering regression fails in CI instead of only // showing up when someone opens the chat page by hand. test('streams a run and renders markdown, code, math and mermaid without leaking Arco into the bubble', async ({ page }) => { await authenticate(page, TEST_ACCESS_KEY, 'research') const api = await mockHermesApi(page) await mockChatSocket(page) await page.goto('/#/hermes/chat') await sendChatMessage(page, 'render everything') const { sessionId } = await waitForRunSessionId(page) await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible() await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest const delta = (text: string) => socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'r1', delta: text }) delta('Hello **bold** and `code`') delta('\n\n```js\nconst a = 1\n```\n') delta('\n$$\nE = mc^2\n$$\n') delta('\n```mermaid\ngraph TD;\nA-->B;\n```\n') }, sessionId) const bubble = page.locator('.message').last() await expect(bubble.locator('.assistant-segment-divider')).toHaveCount(0) await expect(bubble.locator('strong')).toHaveText('bold') await expect(bubble.locator('code')).toHaveCount(2) await expect(bubble.locator('.katex')).toHaveCount(1) // mermaid renders asynchronously into the .mermaid-diagram placeholder await expect(bubble.locator('.mermaid-diagram svg')).toHaveCount(1) // §1 red line: no Arco component inside the message bubble await expect(bubble.locator('.arco-btn, .arco-tag, .arco-input')).toHaveCount(0) // the mock socket has no server-side cancel, so end the run the way the server would await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest socket.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'r1', output: 'done', inputTokens: 5, outputTokens: 3, }) }, sessionId) await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) expect(api.unexpectedRequests).toEqual([]) }) test('does not render empty assistant bubbles between tool calls', async ({ page }) => { await authenticate(page, TEST_ACCESS_KEY, 'research') await mockHermesApi(page) await mockChatSocket(page) await page.goto('/#/hermes/chat') await sendChatMessage(page, 'run tools') const { sessionId } = await waitForRunSessionId(page) await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest const common = { session_id: sid, run_id: 'blank-bubbles' } socket.__trigger('run.started', { ...common, event: 'run.started' }) for (let i = 0; i < 3; i++) { socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: '\n ' }) socket.__trigger('tool.started', { ...common, event: 'tool.started', tool_call_id: `tool-${i}`, tool: 'terminal' }) socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: `tool-${i}`, output: 'done' }) } }, sessionId) await expect(page.locator('.message.assistant')).toHaveCount(1) await expect(page.locator('.message.assistant .message-bubble')).toHaveCount(0) await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'blank-bubbles', delta: '\n' }) socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'blank-bubbles', delta: 'Visible answer' }) }, sessionId) await expect(page.locator('.message.assistant')).toHaveCount(1) await expect(page.locator('.message.assistant')).toContainText('Visible answer') }) test('groups assistant output across tool calls until the run ends', async ({ page }) => { await authenticate(page, TEST_ACCESS_KEY, 'research') await mockHermesApi(page) await mockChatSocket(page) await page.goto('/#/hermes/chat') await sendChatMessage(page, 'inspect') const { sessionId } = await waitForRunSessionId(page) await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest const common = { session_id: sid, run_id: 'grouped-run' } socket.__trigger('run.started', { ...common, event: 'run.started' }) socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: 'First update' }) socket.__trigger('tool.started', { ...common, event: 'tool.started', tool_call_id: 't1', tool: 'terminal' }) socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: 't1', output: 'done' }) socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: 'Second update' }) }, sessionId) const assistant = page.locator('.message.assistant') await expect(assistant).toHaveCount(1) await expect(assistant).toContainText('First update') await expect(assistant).toContainText('Second update') await expect(assistant.locator('.assistant-segment-divider')).toHaveCount(1) await expect(assistant.locator('.message-bubble')).toHaveCount(1) await expect(page.locator('.run-end')).toHaveCount(0) await page.evaluate((sid) => { (window as any).__PW_CHAT_SOCKET__.latest.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'grouped-run' }) }, sessionId) await expect(page.locator('.run-end')).toHaveText('Run output ended') await expect.poll(async () => { const bubble = await assistant.locator('.message-bubble').boundingBox() const end = await page.locator('.run-end').boundingBox() return bubble && end ? end.y - (bubble.y + bubble.height) : Infinity }).toBeLessThan(50) }) test('shows agent activity under its own avatar before the answer', async ({ page }) => { await authenticate(page, TEST_ACCESS_KEY, 'research') await mockHermesApi(page) await mockChatSocket(page) await page.goto('/#/hermes/chat') await sendChatMessage(page, 'think') const { sessionId } = await waitForRunSessionId(page) await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest const common = { session_id: sid, run_id: 'activity-layout' } socket.__trigger('run.started', { ...common, event: 'run.started' }) socket.__trigger('tool.started', { ...common, event: 'tool.started', tool_call_id: 't1', tool: 'terminal' }) }, sessionId) const assistant = page.locator('.message.assistant') await expect(assistant).toHaveCount(1) await expect(assistant.locator('.msg-profile-name')).toHaveText('research') await expect(assistant.locator('.thinking-status-label')).toBeVisible() await expect(assistant.locator('.live-tool-group > summary')).toHaveText('Tool calls (1)') await expect(assistant.locator('.message-bubble')).toHaveCount(0) await expect(page.locator('.thinking-avatar')).toHaveCount(0) await page.emulateMedia({ reducedMotion: 'reduce' }) await expect(assistant.locator('.thinking-status-label')).toHaveCSS('animation-name', 'none') await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest const common = { session_id: sid, run_id: 'activity-layout' } socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: 't1', output: 'done' }) socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: 'Final answer' }) }, sessionId) await expect(assistant).toHaveCount(1) await expect(assistant.locator('.message-bubble')).toContainText('Final answer') const order = await assistant.evaluate(el => { const header = el.querySelector('.msg-agent-header')! const status = el.querySelector('.thinking-status')! const tool = el.querySelector('.live-tool-group')! const body = el.querySelector('.message-bubble')! return !!(header.compareDocumentPosition(status) & Node.DOCUMENT_POSITION_FOLLOWING) && !!(status.compareDocumentPosition(tool) & Node.DOCUMENT_POSITION_FOLLOWING) && !!(tool.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING) }) expect(order).toBe(true) await page.evaluate((sid) => { (window as any).__PW_CHAT_SOCKET__.latest.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'activity-layout' }) }, sessionId) await expect(assistant.locator('.thinking-status')).toHaveCount(0) await expect(assistant.locator('.tool-group > summary')).toHaveText('Tool calls (1)') await expect(assistant.locator('.message-bubble')).toContainText('Final answer') }) test('opens the session model dialog and the session search from the chat header', async ({ page }) => { await authenticate(page, TEST_ACCESS_KEY, 'research') await mockHermesApi(page, { sessions: [sampleSession] }) await mockChatSocket(page) await page.goto('/#/hermes/chat') await page.locator('.session-item').first().click({ button: 'right' }) const ctxPopup = page.locator('.arco-trigger-popup').filter({ hasText: 'Rename' }) await expect(ctxPopup).toBeVisible() await ctxPopup.locator('.arco-dropdown-option', { hasText: 'Set model' }).click() const modelModal = page.locator('.arco-modal').filter({ hasText: 'Set Session Model' }) await expect(modelModal).toBeVisible() await expect(modelModal.getByPlaceholder('Search models...')).toBeVisible() await page.keyboard.press('Escape') await page.getByRole('button', { name: /search/i }).first().click() await expect(page.getByPlaceholder(/search/i).first()).toBeVisible() }) test('assistant replies start below the avatar and profile name on desktop and mobile', async ({ page }, testInfo) => { await authenticate(page, TEST_ACCESS_KEY, 'research') await mockHermesApi(page) await mockChatSocket(page) await page.goto('/#/hermes/chat') await sendChatMessage(page, '检查一下服务状态') const { sessionId } = await waitForRunSessionId(page) await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'avatar-layout', delta: 'UI 收到,通信正常。\n\n这台机器状态:Hermes Web UI 跑在 8648 端口,一切照旧。有活直接说,我随时待命。', }) socket.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'avatar-layout' }) }, sessionId) const message = page.locator('.message.assistant').last() const avatar = message.locator('.msg-avatar') const profileName = message.locator('.msg-profile-name') await expect(profileName).toHaveText('research') const bubble = message.locator('.message-bubble') await expect(bubble).toContainText('UI 收到,通信正常。') for (const width of [1440, 900, 390]) { await page.setViewportSize({ width, height: 900 }) await expect.poll(async () => { const image = await avatar.boundingBox() const reply = await bubble.boundingBox() return image && reply ? reply.y - (image.y + image.height) : -1 }).toBeGreaterThanOrEqual(6) const image = (await avatar.boundingBox())! const reply = (await bubble.boundingBox())! const name = (await profileName.boundingBox())! expect(name.x).toBeGreaterThanOrEqual(image.x + image.width + 6) expect(Math.abs(name.y + name.height / 2 - image.y - image.height / 2)).toBeLessThan(2) expect(name.y + name.height).toBeLessThan(reply.y) expect(Math.abs(reply.x - image.x)).toBeLessThan(1) expect(reply.x + reply.width).toBeLessThanOrEqual(width) expect(await message.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true) if (width === 1440) await message.screenshot({ path: testInfo.outputPath('assistant-below-avatar.png'), animations: 'disabled' }) } })