260 lines
13 KiB
TypeScript
260 lines
13 KiB
TypeScript
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<any>
|
|
}
|
|
|
|
// 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' })
|
|
}
|
|
})
|