import { test, expect, type Page } from '@playwright/test' import type { ActivityCard, Artifact, Conversation, Part, StreamEvent, WorkCardAction } 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, history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }, theme: 'light' | 'dark' | 'molv' = 'light') { let cards = [...initialCards] let actionCount = 0 let releaseAction: (() => void) | undefined let holdAction = false let sent: { text: string; attachmentIds: string[]; taskRef?: { cardId: string; resultVersion: number } } | undefined const workActions: { cardId: string; actionId: string; payload: WorkCardAction }[] = [] let uploadNumber = 0 const errors: string[] = [] page.on('pageerror', error => errors.push(error.message)) await page.addInitScript(({ fontSize, theme }) => { localStorage.setItem('as.conv', 'c1') localStorage.setItem('as.ui', JSON.stringify({ theme, 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, theme }) 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 }, ...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp }, { id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [ { type: 'thinking', process: true, text: '先核对日期' }, { type: 'text', process: true, text: '开始读取材料' }, { type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } }, { type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } }, { type: 'text', text: '最终回复已完成' }, ...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []), ], createdAt: stamp }] : []), ] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 } } else if (path === '/api/conversations/c1/cards') body = { cards } else if (path === '/api/conversations/c1/runs/run-history/events') { const after = Number(url.searchParams.get('after') ?? 0) body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) } } 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 (/^\/api\/cards\/[^/]+\/actions\/work\./.test(path)) { const cardId = path.split('/')[3] const actionId = path.split('/').at(-1)! const payload = request.postDataJSON() as WorkCardAction workActions.push({ cardId, actionId, payload }) cards = cards.map(card => { if (card.id !== cardId || !card.work) return card const next = { ...card, work: { ...card.work } } if (actionId === 'work.read') next.work.readVersion = payload.resultVersion else if (next.work.decision) next.work.decision = { ...next.work.decision, state: actionId === 'work.defer' ? 'deferred' : actionId === 'work.reopen' ? 'open' : 'submitted', ...(actionId === 'work.submit' ? { selectedIds: payload.selectedIds, freeText: payload.freeText, respondedAt: stamp } : {}), } return next }) body = { card: cards.find(card => card.id === cardId) } } 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, workActions, 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 }) }, } } function optionalWork(id = 'optional-work', mode: 'single' | 'multiple' = 'single'): ActivityCard { return work(id, 'done', { title: '说明书材料整理', sourceRunId: 'run-history', work: { resultVersion: 2, readVersion: 0, completedAt: stamp, result: '# 说明书重点\n\n已整理软件结构、角色权限与操作流程,原文件保留。\n\n' + '- **使用角色**:管理员、医生和患者\n- **软件结构**:患者端与医生端连接同一服务端\n- **核心内容**:安装维护、训练操作与安全说明\n\n' + '## 可继续整理的内容\n\n可以先阅读摘要了解全貌,也可以单独提取性能参数、设备要求与需要进一步确认的事项。' + '整理结果仅依据当前上传的说明书;缺少的材料和不能确认的内容会单独列出,不会补写成已确认的结论。' + '如果继续生成交付物,新的结果会更新到独立工作卡,您可以同时讨论其他话题。' + '每项结论都应保留材料范围和来源,便于后续复核。\n\n' + '## 后续核对\n\n在正式采用整理结果前,建议把产品版本、适用设备、使用人员与预期交付形式分别确认。' + '材料中直接写明的内容和整理时发现的问题会分开记录。对没有原文依据的数字、范围或归属,整理结果会保持待确认状态。' + '需要比较其他版本时,可补充相应文件,并说明希望重点比较的部分。\n\n最后一条:需要确认使用场景。', decision: { mode, state: 'open', options: [{ id: 'summary', label: '生成摘要' }, { id: 'facts', label: '提取参数' }] }, }, }) } test('keeps optional choices nonblocking and supports defer, reopen, free input and saved answers', async ({ page }) => { const state = await fixture(page, [optionalWork('optional-work', 'multiple')]) const inline = page.locator('[data-conversation-work] [data-card-id="optional-work"]') await expect(inline.getByRole('checkbox', { name: '生成摘要', exact: true })).toBeVisible() await inline.getByRole('button', { name: '先不选择', exact: true }).click() await expect(inline.getByText('已暂缓选择,您可以继续对话')).toBeVisible() await expect(inline.getByRole('checkbox')).toHaveCount(0) await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('先聊一下本周的安排') await page.getByRole('button', { name: '发送', exact: true }).click() await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排') expect(state.sent?.taskRef).toBeUndefined() // Receiving the POST is earlier than rendering its legitimate foreground reply. await expect(page.locator('.msg.assistant[data-msg-id="live:run1"]')).toBeVisible() const assistantCount = await page.locator('.msg.assistant').count() await inline.getByRole('button', { name: '继续选择', exact: true }).click() await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check() await inline.getByRole('checkbox', { name: '提取参数', exact: true }).check() await inline.getByRole('textbox', { name: '补充要求或直接输入需求' }).fill('保留原文中的来源和限制') await inline.getByRole('button', { name: '开始处理', exact: true }).click() await expect(inline.getByText('已提交需求', { exact: true })).toBeVisible() await expect(inline.getByText('保留原文中的来源和限制', { exact: true })).toBeVisible() await expect(inline.getByRole('textbox')).toHaveCount(0) expect(state.workActions.filter(action => action.actionId === 'work.submit')).toEqual([{ cardId: 'optional-work', actionId: 'work.submit', payload: { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '保留原文中的来源和限制' }, }]) await expect(page.locator('.msg.assistant')).toHaveCount(assistantCount) expect(state.errors).toEqual([]) }) test('opens full results in one place, preserves unread until explicit action and references a version in the composer', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1050 }) const state = await fixture(page, [optionalWork()]) await expect(page.locator('.work-result-notice')).toContainText('1 项工作有新结果') await page.locator('.work-result-notice').click() const card = page.locator('.right-panel [data-card-id="optional-work"]') await expect(page.locator('[data-conversation-work]')).toHaveCount(0) await expect(card.getByRole('radio', { name: '生成摘要', exact: true })).toBeVisible() if (process.env.WORKBENCH_FLOW_SCREENSHOT) await page.screenshot({ path: process.env.WORKBENCH_FLOW_SCREENSHOT, fullPage: true }) await card.getByRole('button', { name: '查看完整结果', exact: true }).click() await expect(card.getByText('最后一条:需要确认使用场景。', { exact: false })).toBeVisible() expect(state.workActions.filter(action => action.actionId === 'work.read')).toHaveLength(0) await card.getByRole('button', { name: '基于此结果继续', exact: true }).click() await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toContainText('第 2 版结果') await expect.poll(() => state.workActions.filter(action => action.actionId === 'work.read').length).toBe(1) await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('解释一下需要确认的事项') await page.getByRole('button', { name: '发送', exact: true }).click() await expect.poll(() => state.sent?.taskRef).toEqual({ cardId: 'optional-work', resultVersion: 2 }) expect(state.sent?.text).toBe('解释一下需要确认的事项') await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toHaveCount(0) await expect(page.locator('.msg.user time[datetime]')).toHaveCount(2) await expect(page.locator('.msg.assistant time[datetime]')).toHaveCount(2) expect(state.errors).toEqual([]) }) test('retains text, uploads and the result reference when mixed input is not supported', async ({ page }) => { const state = await fixture(page, [optionalWork()]) await page.locator('.work-result-notice').click() await page.getByRole('button', { name: '基于此结果继续', exact: true }).click() await page.getByLabel('添加材料', { exact: true }).setInputFiles({ name: '补充材料.txt', mimeType: 'text/plain', buffer: Buffer.from('补充要求') }) const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true }) await input.fill('结合补充材料解释重点') await page.getByRole('button', { name: '发送', exact: true }).click() await expect(input).toHaveValue('结合补充材料解释重点') await expect(page.locator('.attach-chip')).toContainText('补充材料.txt') await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toBeVisible() expect(state.sent).toBeUndefined() }) test('keeps middle and right scrolling independent while retaining full execution output', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 800 }) const events: StreamEvent[] = [ { seq: 1, type: 'thinking.delta', payload: { text: '已保存的核对思路' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }, { seq: 2, type: 'tool.call', payload: { callId: 'call-record', name: 'fs_read_file', arguments: '{"path":"说明书"}' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }, { seq: 3, type: 'tool.output', payload: { callId: 'call-record', text: '完整的文档读取记录' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }, { seq: 4, type: 'tool.result', payload: { callId: 'call-record', status: 'ok' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }, ] const cards = [optionalWork(), ...Array.from({ length: 15 }, (_, index) => work(`active-${index}`, 'active', { title: `其他工作 ${index + 1}` }))] const state = await fixture(page, cards, 14, { events, savedParts: [{ type: 'text', text: '继续讨论当前主题。\n\n'.repeat(100) }] }) await page.getByRole('button', { name: '打开工作', exact: true }).click() const right = page.locator('.right-panel [data-work-scroll]') const chat = page.locator('.chat-body') await right.evaluate(element => { element.scrollTop = 180 }) const rightTop = await right.evaluate(element => element.scrollTop) await chat.evaluate(element => { element.scrollTop = 80; element.dispatchEvent(new Event('scroll', { bubbles: true })) }) expect(await right.evaluate(element => element.scrollTop)).toBe(rightTop) const chatTop = await chat.evaluate(element => element.scrollTop) await right.evaluate(element => { element.scrollTop = 240; element.dispatchEvent(new Event('scroll', { bubbles: true })) }) expect(await chat.evaluate(element => element.scrollTop)).toBe(chatTop) await page.locator('.right-panel [data-card-id="optional-work"]').getByRole('button', { name: '查看完整执行记录', exact: true }).click() const record = page.getByRole('region', { name: '说明书材料整理的执行记录' }) await expect(record.getByText('已保存的核对思路', { exact: true })).toBeVisible() await record.getByRole('button', { name: /查看记录/ }).click() await expect(record.getByText('完整的文档读取记录', { exact: true })).toBeVisible() await expect(page.locator('.work-execution-record')).toHaveCount(1) await record.getByRole('button', { name: '返回工作', exact: true }).click() await expect(page.locator('.right-panel [data-card-id="optional-work"]')).toBeVisible() expect(state.errors).toEqual([]) }) test('folds read task history newest first and retains submitted choices after reload', async ({ page }) => { const older = optionalWork('older') const latest = optionalWork('latest') older.title = '旧材料整理'; latest.title = '新材料整理' older.work = { ...older.work!, readVersion: 2, decision: { mode: 'single', state: 'submitted', selectedIds: ['summary'], freeText: '保留参数表', respondedAt: stamp, options: [{ id: 'summary', label: '生成摘要' }] } } latest.work = { ...latest.work!, readVersion: 2, decision: { mode: 'single', state: 'deferred' } } latest.updatedAt = '2026-10-13T10:00:00Z' const state = await fixture(page, [older, latest]) await page.getByRole('button', { name: '打开工作', exact: true }).click() await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(0) await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click() await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(2) expect(await page.locator('.right-panel [data-card-id]').evaluateAll(elements => elements.map(element => element.getAttribute('data-card-id')))).toEqual(['latest', 'older']) await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible() await page.reload() await page.getByRole('button', { name: '打开工作', exact: true }).click() await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click() await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible() await expect(page.locator('.right-panel [data-card-id="older"]').getByRole('textbox')).toHaveCount(0) expect(state.errors).toEqual([]) }) 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('places the full work card only in the desktop work pane', 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]')).toHaveCount(0) await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0) await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible() if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true }) expect(state.errors).toEqual([]) }) test('uses a focused reading column and a compact authored-message shape', async ({ page }) => { await page.setViewportSize({ width: 1920, height: 1080 }) const state = await fixture(page) await page.getByRole('button', { name: '打开工作', exact: true }).click() const conversation = page.locator('.chat-body .msg').first() const authored = page.locator('.chat-body .msg.user').first() const answer = page.locator('.chat-body .msg.assistant').first() const [conversationBox, authoredBox, answerBox] = await Promise.all([ conversation.boundingBox(), authored.boundingBox(), answer.boundingBox(), ]) expect(conversationBox).not.toBeNull() expect(authoredBox).not.toBeNull() expect(answerBox).not.toBeNull() expect(conversationBox!.width).toBeLessThanOrEqual(840) expect(authoredBox!.width).toBeLessThanOrEqual(680) expect(authoredBox!.x + authoredBox!.width).toBeCloseTo(answerBox!.x + answerBox!.width, 0) const surfaces = await page.evaluate(() => { const pick = (selector: string) => { const element = document.querySelector(selector) if (!element) throw new Error(`missing ${selector}`) const style = getComputedStyle(element) return { background: style.backgroundColor, color: style.color, borderRadius: Number.parseFloat(style.borderRadius), paddingLeft: Number.parseFloat(style.paddingLeft), } } return { chrome: pick('.companion-topbar'), context: pick('.left-nav'), conversation: pick('.main'), answer: pick('.msg.assistant'), authored: pick('.msg.user .bubble'), } }) const rgb = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0] const distance = (a: string, b: string) => rgb(a).reduce((sum, value, index) => sum + Math.abs(value - rgb(b)[index]), 0) expect(distance(surfaces.chrome.background, surfaces.conversation.background)).toBeGreaterThan(300) expect(distance(surfaces.context.background, surfaces.conversation.background)).toBeGreaterThan(300) expect(distance(surfaces.authored.background, surfaces.answer.background)).toBeGreaterThan(300) expect(surfaces.answer.borderRadius).toBeGreaterThanOrEqual(12) expect(surfaces.answer.paddingLeft).toBeGreaterThanOrEqual(20) const activeCard = page.locator('.right-panel .acard.status-active') await expect(activeCard).toBeVisible() const cardStyle = await activeCard.evaluate(element => { const style = getComputedStyle(element) return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth } }) const panelBackground = await page.locator('.right-panel').evaluate(element => getComputedStyle(element).backgroundColor) expect(cardStyle.background).not.toBe(panelBackground) expect(Number.parseFloat(cardStyle.borderLeftWidth)).toBeGreaterThanOrEqual(3) expect(state.errors).toEqual([]) }) for (const theme of ['light', 'dark', 'molv'] as const) { test(`keeps the workbench frame legible in ${theme}`, async ({ page }) => { await page.setViewportSize({ width: 1440, height: 960 }) const state = await fixture(page, [progress], 14, undefined, theme) await page.getByRole('button', { name: '打开工作', exact: true }).click() const surfaces = await page.evaluate(() => ['.companion-topbar', '.left-nav', '.msg.user .bubble', '.msg.assistant'].map(selector => { const element = document.querySelector(selector) if (!element) throw new Error(`missing ${selector}`) const style = getComputedStyle(element) return { selector, foreground: style.color, background: style.backgroundColor } })) const channels = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0] const luminance = (value: string) => { const linear = channels(value).map(channel => { const normalized = channel / 255 return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 }) return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] } for (const surface of surfaces) { const lighter = Math.max(luminance(surface.foreground), luminance(surface.background)) const darker = Math.min(luminance(surface.foreground), luminance(surface.background)) expect((lighter + 0.05) / (darker + 0.05), `${surface.selector} contrast in ${theme}`).toBeGreaterThanOrEqual(4.5) } 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('keeps one confirmation action location when the work pane is visible', async ({ page }) => { const state = await fixture(page, [confirmation]) await page.getByRole('button', { name: '打开工作', exact: true }).click() state.hold() await expect(page.locator('[data-conversation-work]')).toHaveCount(0) await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click() await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled() await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0) expect(state.actionCount).toBe(1) state.release() await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0) expect(state.errors).toEqual([]) }) test('keeps each run process folded and reloadable after the final reply', async ({ page }) => { const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent => ({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }) const events = [ event(1, 'thinking.delta', { index: 0, text: '先核对日期' }), event(2, 'message.delta', { index: 0, text: '开始读取材料' }), event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }), event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }), event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }), event(6, 'run.phase', { phase: 'finalizing' }), event(7, 'message.delta', { index: 1, text: '最终回复已完成' }), ] const state = await fixture(page, [], 14, { events }) const message = page.locator('[data-msg-id="m4"]') await expect(message.getByText('最终回复已完成')).toBeVisible() await expect(message.getByText('开始读取材料')).toHaveCount(0) await message.getByRole('button', { name: /执行记录/ }).click() await expect(message.getByText('先核对日期')).toBeVisible() await expect(message.getByText('开始读取材料')).toBeVisible() await message.locator('.tool-block-head').click() await expect(message.getByText('完整工具输出')).toBeVisible() await page.reload() const restored = page.locator('[data-msg-id="m4"]') await expect(restored.getByText('最终回复已完成')).toBeVisible() await restored.getByRole('button', { name: /执行记录/ }).click() await expect(restored.getByText('先核对日期')).toBeVisible() await restored.locator('.tool-block-head').click() await expect(restored.getByText('完整工具输出')).toBeVisible() expect(state.errors).toEqual([]) }) test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => { const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent => ({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 }) const events = [ event(1, 'message.delta', { index: 0, text: '已解析的思考' }), event(2, 'message.delta', { index: 1, text: '开始读取' }), event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }), event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }), event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }), event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }), event(7, 'run.phase', { phase: 'finalizing' }), event(8, 'message.delta', { index: 1, text: '最终回复已完成' }), event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'), event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'), ] const savedParts: Part[] = [ { type: 'text', process: true, text: '模型在输出前失败,正在重试' }, { type: 'thinking', process: true, text: '已解析的思考' }, { type: 'text', process: true, text: '开始读取材料' }, { type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } }, { type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }, { type: 'text', text: '最终回复已完成' }, ] await fixture(page, [], 14, { events, savedParts }) const message = page.locator('[data-msg-id="m4"]') await message.getByRole('button', { name: /执行记录/ }).click() await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible() await expect(message.getByText('已解析的思考')).toBeVisible() await expect(message.getByText('开始读取材料')).toBeVisible() await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0) await expect(message.getByText('协作进展:正在核对附录')).toBeVisible() await expect(message.getByText('协作结束:附录已核对')).toBeVisible() await expect(message.getByText('已解析的思考')).toHaveCount(0) await message.locator('.tool-block-head').click() await expect(message.getByText('完整输出留存')).toBeVisible() }) test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => { const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent => ({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 }) await fixture(page, [], 14, { events: [ event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }), event(2, 'run.phase', { phase: 'finalizing' }), event(3, 'message.delta', { index: 0, text: '已完成' }), event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'), ], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] }) const message = page.locator('[data-msg-id="m4"]') await message.getByRole('button', { name: /执行记录/ }).click() await expect(message.getByText('协作结束:已完成')).toBeVisible() }) test('locates a completed card even when work is filtered to project pending items', async ({ page }) => { const result = work('result1', 'done', { kind: 'result', title: '材料核对结果', body: [{ type: 'markdown', markdown: '完整结论在这里' }] }) await fixture(page, [result], 14, { events: [], cardRef: 'result1' }) await page.getByRole('button', { name: '打开工作', exact: true }).click() const workPane = page.locator('.right-panel') await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click() await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click() await expect(workPane.getByText('完整结论在这里')).toHaveCount(0) await page.locator('[data-msg-id="m4"] .card-anchor-locate').click() await expect(workPane.getByText('完整结论在这里')).toBeVisible() await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/) await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/) }) 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([]) }) }