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 { 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 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(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([]) }) }