Phase 006: migrate the chat page from naive-ui to Arco Design Vue
Swaps the rendering layer only. Chat store logic, keyboard handling, and the /chat-run Socket.IO contract are untouched, so a mixed UI cannot hide a chat regression behind a library swap. Arco's dropdowns take their items from a #content slot of a-doption children rather than a droplist prop, and Arco Triggers cannot be nested, so the two toolbar menus lost their a-tooltip in favour of a native title. Both rules are recorded in docs/arco-mapping.md, and tests/e2e/chat-input-dropdown.spec.ts plus tests/e2e/chat-rendering-smoke.spec.ts keep the menus, the streaming markdown rendering, and the no-Arco-inside-the-bubble red line from regressing silently.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// Arco 的 a-dropdown 没有 droplist prop:菜单内容必须走 #content 插槽 + a-doption。
|
||||
// 传 :droplist 会被静默忽略,弹层能打开但列表是空的(历史上就是这么坏的)。
|
||||
test('reasoning effort dropdown renders its options and applies the selection', 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('.reasoning-effort-button').click()
|
||||
const popup = page.locator('.arco-trigger-popup').filter({ hasText: 'Minimal' })
|
||||
await expect(popup).toBeVisible()
|
||||
await expect(popup.locator('.arco-dropdown-option')).toHaveCount(7)
|
||||
|
||||
await popup.locator('.arco-dropdown-option', { hasText: 'High' }).first().click()
|
||||
await expect(page.locator('.reasoning-effort-label')).toHaveText('High')
|
||||
})
|
||||
|
||||
test('input settings dropdown renders its options and toggles auto play voice', 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('.input-settings-button').click()
|
||||
const popup = page.locator('.arco-trigger-popup').filter({ hasText: 'Auto-play voice' })
|
||||
await expect(popup).toBeVisible()
|
||||
await expect(popup.locator('.arco-dropdown-option')).toHaveCount(2)
|
||||
|
||||
await popup.locator('.arco-dropdown-option').first().click()
|
||||
await expect(page.locator('.settings-check').first()).toHaveText('✓')
|
||||
})
|
||||
|
||||
test('session context menu renders flat items and its export submenu', 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 popup = page.locator('.arco-trigger-popup').filter({ hasText: 'Rename' })
|
||||
await expect(popup).toBeVisible()
|
||||
// pin / rename / archive / workspace / model / export(submenu) / open-link / copy-link / copy-id
|
||||
await expect(popup.locator('.arco-dropdown-option')).toHaveCount(9)
|
||||
|
||||
await popup.locator('.arco-dropdown-option', { hasText: 'Export' }).click()
|
||||
await expect(page.locator('.arco-trigger-popup').filter({ hasText: 'Compressed' })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
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('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('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()
|
||||
})
|
||||
Reference in New Issue
Block a user