Why: Retained React nodes alone did not prove actual browser scrolling was preserved. Evidence: The settled Chromium case failed on the previous bundle, then all 11 browser cases passed with exact material, scroll and focus assertions. Impact: Browser regression coverage only; fixture transport stays outside the production bundle. Refs: UI10, ce-code-review UI finding #3, work-companion-ui
229 lines
15 KiB
TypeScript
229 lines
15 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test'
|
|
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
|
|
|
|
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
|
|
|
const stamp = '2026-10-12T10:00:00Z'
|
|
const project = { id: 'p1', name: '产品升级计划', createdAt: stamp, updatedAt: stamp }
|
|
const conversations: Conversation[] = [
|
|
{ id: 'c1', projectId: 'p1', title: '整理项目材料', createdAt: stamp, updatedAt: stamp },
|
|
{ id: 'c2', projectId: 'p1', title: '讨论本周工作', createdAt: stamp, updatedAt: stamp },
|
|
]
|
|
const artifact: Artifact = { id: 'doc1', projectId: 'p1', name: '材料整理摘要.md', kind: 'markdown', size: 64, createdAt: stamp, updatedAt: stamp }
|
|
function work(id: string, status: ActivityCard['status'], over: Partial<ActivityCard> = {}): ActivityCard {
|
|
return { id, status, projectId: 'p1', conversationId: 'c1', kind: 'progress', title: '整理项目材料', createdAt: stamp, updatedAt: stamp, ...over }
|
|
}
|
|
const progress = work('work1', 'active', { body: [{ type: 'kv', kv: [
|
|
['状态', '进行中'], ['正在做', '核对会议结论'], ['当前发现', '两份材料的时间安排需要核对'], ['剩余', '评审意见仍待核查'], ['材料键', 'private-material-key'],
|
|
] }] })
|
|
const confirmation = work('confirm1', 'action_required', { kind: 'confirm', title: '生成项目草稿',
|
|
body: [{ type: 'markdown', markdown: '依据三份材料新增一份草稿,保留原件,不对外发送' }],
|
|
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
|
|
})
|
|
|
|
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
|
|
let cards = [...initialCards]
|
|
let actionCount = 0
|
|
let releaseAction: (() => void) | undefined
|
|
let holdAction = false
|
|
let sent: { text: string; attachmentIds: string[] } | undefined
|
|
let uploadNumber = 0
|
|
const errors: string[] = []
|
|
page.on('pageerror', error => errors.push(error.message))
|
|
await page.addInitScript(({ fontSize }) => {
|
|
localStorage.setItem('as.conv', 'c1')
|
|
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
|
|
const streams: { url: string; emit: (event: unknown) => void }[] = []
|
|
class TestEventSource {
|
|
onopen: (() => void) | null = null
|
|
onerror = null
|
|
onmessage: ((event: { data: string }) => void) | null = null
|
|
listeners: Record<string, ((event: { data: string }) => void)[]> = {}
|
|
constructor(public url: string) { streams.push(this); queueMicrotask(() => this.onopen?.()) }
|
|
addEventListener(type: string, callback: (event: { data: string }) => void) { (this.listeners[type] ??= []).push(callback) }
|
|
emit(event: unknown) {
|
|
const value = event as { type: string }
|
|
for (const callback of this.listeners[value.type] ?? []) callback({ data: JSON.stringify(event) })
|
|
}
|
|
close() {}
|
|
}
|
|
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
|
|
}, { fontSize })
|
|
await page.route('**/api/**', async route => {
|
|
const request = route.request()
|
|
const url = new URL(request.url())
|
|
const path = url.pathname
|
|
let body: unknown = []
|
|
if (path === '/api/health') body = { ok: true }
|
|
else if (path === '/api/me') body = { owner: 'reader', name: '林老师', auth: false, isAdmin: false }
|
|
else if (path === '/api/projects') body = [project]
|
|
else if (path === '/api/conversations') body = conversations
|
|
else if (path === '/api/projects/p1/conversations' && request.method() === 'POST') body = conversations[1]
|
|
else if (/^\/api\/conversations\/c[12]$/.test(path)) {
|
|
const id = path.split('/').at(-1)!
|
|
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
|
|
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
|
|
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
|
|
] : [], runs: [], activeHitl: [], lastSeq: 10 }
|
|
} else if (path === '/api/conversations/c1/cards') body = { cards }
|
|
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
|
|
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
|
else if (path === '/api/projects/p1/artifacts') body = [artifact]
|
|
else if (path === '/api/artifacts/doc1/content') {
|
|
await route.fulfill({ status: 200, contentType: 'text/markdown', body: '# 材料整理摘要\n\n已整理两份材料,评审意见仍待核对' }); return
|
|
} else if (path === '/api/settings') body = { user: { runtime: { mock: false } }, project: {}, effective: {}, schema: [] }
|
|
else if (path === '/api/profiles') body = [{ id: 'default', name: '工作助手', model: {}, tools: { allow: [] }, context: { maxHistoryMessages: 20 }, hitl: { askOnAmbiguity: true }, systemPrompt: '' }]
|
|
else if (path === '/api/projects/p1/uploads') body = { artifactId: 'upload' + ++uploadNumber, filename: '材料.txt', size: 16 }
|
|
else if (/\/messages$/.test(path) && request.method() === 'POST') {
|
|
sent = request.postDataJSON()
|
|
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
|
|
} else if (path === '/api/cards/confirm1/actions/confirm') {
|
|
actionCount++
|
|
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
|
|
cards = cards.map(card => card.id === 'confirm1' ? { ...card, status: 'resolved' } : card)
|
|
body = { card: cards.find(card => card.id === 'confirm1') }
|
|
}
|
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
|
})
|
|
await page.goto('/')
|
|
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
|
|
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
|
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
|
|
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
|
|
await page.evaluate(({ type, payload, seq }) => {
|
|
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
|
|
const stream = win.__workStreams.findLast(s => s.url.includes('conversationId=c1'))
|
|
if (!stream) throw Error('conversation stream unavailable')
|
|
stream.emit({ type, payload, seq, conversationId: 'c1', ts: '2026-10-12T10:00:00Z', v: 1 })
|
|
}, { type, payload, seq })
|
|
},
|
|
}
|
|
}
|
|
|
|
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
|
|
const state = await fixture(page)
|
|
await expect(page.locator('.right-panel')).toBeHidden()
|
|
await expect(page.getByText('核对会议结论', { exact: true }).first()).toBeVisible()
|
|
await expect(page.getByText('private-material-key')).toHaveCount(0)
|
|
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
|
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看执行记录', exact: true }).click()
|
|
await expect(page.locator('[data-conversation-work]').getByText('private-material-key')).toBeVisible()
|
|
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
|
await state.emit('card.status', { cardId: 'work1', status: 'active' }, 11)
|
|
await expect(page.locator('[data-conversation-work] .acard-status')).toHaveText('已完成')
|
|
expect(state.errors).toEqual([])
|
|
})
|
|
|
|
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 960 })
|
|
const state = await fixture(page)
|
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
|
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
|
|
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
|
|
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
|
|
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
|
|
expect(state.errors).toEqual([])
|
|
})
|
|
|
|
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 960 })
|
|
const tabbed = work('work1', 'active', { body: [...progress.body!, {
|
|
type: 'tabs', id: 'materials', tabs: { tabs: [
|
|
{ id: 'first', label: '第一份材料', nodes: [{ type: 'markdown', markdown: '第一份材料的范围' }] },
|
|
{ id: 'second', label: '第二份材料', nodes: [{ type: 'markdown', markdown: Array.from({ length: 70 }, (_, i) => `待核对事项 ${i + 1}`).join('\n\n') }] },
|
|
] },
|
|
}] })
|
|
const state = await fixture(page, [tabbed, work('peer', 'active', { body: [] })])
|
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
|
const card = page.locator('.right-panel [data-card-id="work1"]')
|
|
await card.getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
|
const tab = card.getByRole('tab', { name: '第二份材料', exact: true })
|
|
await tab.click()
|
|
await tab.focus()
|
|
const details = card.locator('.acard-detail-body')
|
|
await expect.poll(() => details.evaluate(element => element.clientHeight)).toBe(420)
|
|
const physical = await details.evaluate(element => {
|
|
Object.assign(window, { __workReading: element })
|
|
element.scrollTop = 137
|
|
return { height: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop }
|
|
})
|
|
expect(physical.scrollHeight).toBeGreaterThan(physical.height)
|
|
expect(physical.top).toBe(137)
|
|
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
|
await expect(card.locator('.acard-status')).toHaveText('已完成')
|
|
await expect(tab).toHaveAttribute('aria-selected', 'true')
|
|
expect(await details.evaluate(element => ({
|
|
retained: (window as typeof window & { __workReading: Element }).__workReading === element,
|
|
top: element.scrollTop,
|
|
}))).toEqual({ retained: true, top: 137 })
|
|
expect(await tab.evaluate(element => document.activeElement === element)).toBe(true)
|
|
expect(state.errors).toEqual([])
|
|
})
|
|
|
|
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
|
|
const state = await fixture(page, [confirmation])
|
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
|
state.hold()
|
|
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
|
|
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
|
|
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
|
|
expect(state.actionCount).toBe(1)
|
|
state.release()
|
|
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
|
expect(state.errors).toEqual([])
|
|
})
|
|
|
|
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
|
|
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
|
|
const state = await fixture(page, [result])
|
|
await page.locator('[data-conversation-work]').getByText(artifact.name, { exact: true }).click()
|
|
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
|
await expect(page.locator('.preview-pane-body')).toContainText('评审意见仍待核对')
|
|
await state.emit('surface.updated', { identity: { surfaceId: 'new-page', owner: 'reader', pluginId: 'generative-ui', conversationId: 'c1' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新任务页面', nodes: [] } }, 11)
|
|
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
|
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
|
await expect(page.locator('.main')).toBeHidden()
|
|
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
|
await expect(page.locator('.main')).toBeVisible()
|
|
expect(state.errors).toEqual([])
|
|
})
|
|
|
|
test('sends three attachments without text and states foreground queueing truthfully', async ({ page }) => {
|
|
const state = await fixture(page, [])
|
|
await page.locator('input[type=file]').first().setInputFiles([
|
|
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议内容') },
|
|
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求内容') },
|
|
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审内容') },
|
|
])
|
|
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
|
await expect.poll(() => state.sent?.attachmentIds.length).toBe(3)
|
|
expect(state.sent?.text).toBe('')
|
|
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('请优先核对时间安排')
|
|
await expect(page.getByRole('button', { name: '加入队列', exact: true })).toBeEnabled()
|
|
await page.getByRole('button', { name: '加入队列', exact: true }).click()
|
|
await expect(page.getByText('请优先核对时间安排', { exact: true })).toBeVisible()
|
|
await expect(page.locator('.input-status')).toContainText('排队')
|
|
expect(state.errors).toEqual([])
|
|
})
|
|
|
|
for (const width of [1440, 1024, 736, 390, 320]) {
|
|
test(`keeps navigation and key actions usable at ${width}px with large UI type`, async ({ page }) => {
|
|
await page.setViewportSize({ width, height: 960 })
|
|
const state = await fixture(page, [progress], 18)
|
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
|
await expect(page.locator('.right-panel')).toBeVisible()
|
|
await expect(page.locator('.right-panel').getByText('评审意见仍待核查')).toBeVisible()
|
|
if (width < 1024) {
|
|
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
|
await expect(page.locator('.main')).toBeVisible()
|
|
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
|
await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible()
|
|
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
|
}
|
|
const layout = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }))
|
|
expect(layout.scroll).toBeLessThanOrEqual(layout.width)
|
|
expect(state.errors).toEqual([])
|
|
})
|
|
}
|