Why: Upload inventory alone did not prove that the attachment-only user message included and persisted all three materials. Evidence: Real upload IDs match the empty-text POST and the same user artifact_ref parts before and after reload; both live cases pass. Impact: Stronger acceptance evidence only; retain mock-runner model-quality limits. Refs: ce-code-review 20261012-165912-cb241b46 #4, UI01/UI04/U5
133 lines
7.8 KiB
TypeScript
133 lines
7.8 KiB
TypeScript
import { test, expect, type Page, type APIRequestContext, type Response } from '@playwright/test'
|
|
import type { Artifact, Message, Part, Run, UploadInfo } from '../src/contracts'
|
|
|
|
test.skip(!process.env.WORK_COMPANION_LIVE_BROWSER, 'Requires the task-owned backend, SQLite data and MockRunner')
|
|
|
|
async function openRealConversation(page: Page, request: APIRequestContext) {
|
|
const projectResponse = await request.post('/api/projects', { data: { name: '工作台验收 ' + Date.now() } })
|
|
expect(projectResponse.ok()).toBe(true)
|
|
const project = await projectResponse.json()
|
|
const conversationResponse = await request.post(`/api/projects/${project.id}/conversations`, { data: { title: '真实接口验收' } })
|
|
expect(conversationResponse.ok()).toBe(true)
|
|
const conversation = await conversationResponse.json()
|
|
await page.addInitScript(({ projectId, conversationId }) => {
|
|
localStorage.setItem('as.conv', conversationId)
|
|
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', rightCollapsed: true, lastProjectId: projectId }))
|
|
}, { projectId: project.id, conversationId: conversation.id })
|
|
await page.goto('/')
|
|
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
|
return { project, conversation }
|
|
}
|
|
|
|
test('real upload/send/reload preserves three actual attachments', async ({ page, request }, testInfo) => {
|
|
const { project, conversation } = await openRealConversation(page, request)
|
|
const errors: string[] = []
|
|
page.on('pageerror', error => errors.push(error.message))
|
|
const uploadResponses: Response[] = []
|
|
const uploadPath = `/api/projects/${project.id}/uploads`
|
|
const observeUpload = (response: Response) => {
|
|
if (response.request().method() === 'POST' && new URL(response.url()).pathname === uploadPath) uploadResponses.push(response)
|
|
}
|
|
page.on('response', observeUpload)
|
|
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)
|
|
page.off('response', observeUpload)
|
|
expect(uploadResponses).toHaveLength(3)
|
|
const uploads: UploadInfo[] = await Promise.all(uploadResponses.map(async response => {
|
|
expect(response.ok()).toBe(true)
|
|
return response.json()
|
|
}))
|
|
expect(uploads.map(upload => upload.filename)).toEqual(['会议纪要.txt', '需求说明.txt', '评审意见.txt'])
|
|
const attachmentIds = uploads.map(upload => upload.artifactId)
|
|
for (const id of attachmentIds) expect(id).toEqual(expect.stringMatching(/\S+/))
|
|
expect(new Set(attachmentIds).size).toBe(3)
|
|
const artifactsResponse = await request.get(`/api/projects/${project.id}/artifacts`)
|
|
expect(artifactsResponse.ok()).toBe(true)
|
|
const artifacts: Artifact[] = await artifactsResponse.json()
|
|
expect(artifacts).toHaveLength(3)
|
|
expect(artifacts.map(artifact => artifact.id).sort()).toEqual([...attachmentIds].sort())
|
|
const expectedParts: Part[] = uploads.map(upload => {
|
|
const artifact = artifacts.find(artifact => artifact.id === upload.artifactId)!
|
|
expect(artifact.name).toBe(upload.filename)
|
|
return { type: 'artifact_ref', artifact: { artifactId: artifact.id, name: artifact.name, kind: artifact.kind } }
|
|
})
|
|
const messagePath = `/api/conversations/${conversation.id}/messages`
|
|
const [sendRequest, sendResponse] = await Promise.all([
|
|
page.waitForRequest(request => request.method() === 'POST' && new URL(request.url()).pathname === messagePath),
|
|
page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === messagePath),
|
|
page.getByRole('button', { name: '发送', exact: true }).click(),
|
|
])
|
|
const sent = sendRequest.postDataJSON()
|
|
expect(sent.text).toBe('')
|
|
expect(sent.attachmentIds).toEqual(attachmentIds)
|
|
expect(sendResponse.ok()).toBe(true)
|
|
const accepted: { message: Message; run: Run } = await sendResponse.json()
|
|
expect(accepted.message.role).toBe('user')
|
|
expect(accepted.message.conversationId).toBe(conversation.id)
|
|
expect(accepted.message.parts).toEqual(expectedParts)
|
|
expect(accepted.run.kind).toBe('conversation')
|
|
await expect.poll(async () => {
|
|
const response = await request.get(`/api/conversations/${conversation.id}`)
|
|
expect(response.ok()).toBe(true)
|
|
const current: { runs: Run[] } = await response.json()
|
|
return current.runs.some(run => run.id === accepted.run.id && run.kind === 'conversation' && run.status === 'completed')
|
|
}).toBe(true)
|
|
async function persistedMessage() {
|
|
const response = await request.get(`/api/conversations/${conversation.id}`)
|
|
expect(response.ok()).toBe(true)
|
|
const current: { messages: Message[] } = await response.json()
|
|
const users = current.messages.filter(message => message.role === 'user')
|
|
expect(users).toHaveLength(1)
|
|
expect(users[0].id).toBe(accepted.message.id)
|
|
expect(users[0].conversationId).toBe(conversation.id)
|
|
expect(users[0].parts).toEqual(expectedParts)
|
|
return users[0]
|
|
}
|
|
const beforeReload = await persistedMessage()
|
|
await page.reload()
|
|
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
|
const afterReload = await persistedMessage()
|
|
expect(afterReload).toEqual(beforeReload)
|
|
const association = { projectId: project.id, conversationId: conversation.id, runId: accepted.run.id,
|
|
uploads: uploads.map(({ artifactId, filename }) => ({ artifactId, filename })),
|
|
sent: { text: sent.text, attachmentIds: sent.attachmentIds }, beforeReload, afterReload }
|
|
await testInfo.attach('real-material-associations', { body: Buffer.from(JSON.stringify(association, null, 2)), contentType: 'application/json' })
|
|
console.log('real-material-associations', JSON.stringify(association))
|
|
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
|
await expect(page.locator('[data-artifact-row]')).toHaveCount(3)
|
|
await page.locator('[data-artifact-row]').first().click()
|
|
await expect(page.locator('.preview-pane-name')).toBeVisible()
|
|
expect(errors).toEqual([])
|
|
})
|
|
|
|
test('real background card and HITL decision survive query reconciliation', async ({ page, request }) => {
|
|
const { conversation } = await openRealConversation(page, request)
|
|
const errors: string[] = []
|
|
page.on('pageerror', error => errors.push(error.message))
|
|
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
|
|
// Backend's deterministic runner exercises the real run/card/event lifecycle;
|
|
// this is not a live-model quality or stage-findings test.
|
|
await input.fill('#background')
|
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
|
await expect.poll(async () => {
|
|
const out = await request.get(`/api/conversations/${conversation.id}/cards`).then(r => r.json())
|
|
return out.cards?.some((card: { kind: string; status: string }) => card.kind === 'progress' && ['done', 'failed'].includes(card.status))
|
|
}, { timeout: 15000 }).toBe(true)
|
|
await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
|
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeVisible()
|
|
await input.fill('#hitl')
|
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
|
await expect(page.locator('.hitl-card')).toBeVisible()
|
|
await page.locator('.hitl-card').getByRole('button', { name: /Markdown 文档/ }).click()
|
|
await expect(page.locator('.hitl-card')).toHaveCount(0)
|
|
await expect.poll(async () => {
|
|
const current = await request.get(`/api/conversations/${conversation.id}`).then(r => r.json())
|
|
return current.activeHitl.length
|
|
}).toBe(0)
|
|
expect(errors).toEqual([])
|
|
})
|