diff --git a/e2e/work-companion-live.playwright.ts b/e2e/work-companion-live.playwright.ts index a2c7fb6..b3be563 100644 --- a/e2e/work-companion-live.playwright.ts +++ b/e2e/work-companion-live.playwright.ts @@ -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()