Verify exact live material associations across reload (U5)

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
This commit was merged in pull request #74.
This commit is contained in:
2026-10-12 18:55:55 +00:00
parent 91a832f93e
commit 43d1933b8c
+66 -6
View File
@@ -1,4 +1,5 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
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')
@@ -18,25 +19,84 @@ async function openRealConversation(page: Page, request: APIRequestContext) {
return { project, conversation }
}
test('real upload/send/reload preserves three actual attachments', async ({ page, request }) => {
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)
const artifacts = await request.get(`/api/projects/${project.id}/artifacts`).then(r => r.json())
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)
await page.getByRole('button', { name: '发送', exact: true }).click()
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 current = await request.get(`/api/conversations/${conversation.id}`).then(r => r.json())
return current.runs.some((run: { kind: string; status: string }) => run.kind === 'conversation' && run.status === 'completed')
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()