import { expect, test, type Page } from '@playwright/test'; async function mockApi(page: Page) { const calls: string[] = []; const history: Record = { 7: { id: 7, title: '历史会话', course_version_id: 1, batch_id: null, project_id: null, scope_locked: true, messages: [{ id: 1, role: 'user', content: '旧问题' }, { id: 2, role: 'assistant', content: '# 标题\n\n段落 [安全链接](https://example.com) [危险链接](javascript:alert(1)) \n\n> 引用\n\n- 列表\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```js\nconst x = 1;\n```', citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/knowledge/documents/10/pages/2', image_url: '/api/knowledge/documents/10/pages/2/image' }, { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/knowledge/documents/10/pages/2', image_url: '/api/knowledge/documents/10/pages/2/image' }, { chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/knowledge/documents/12/pages/48', image_url: '/api/knowledge/documents/12/pages/48/image' }, { title: '已迁移评分证据.pdf', page: 50, quote: '评分证据', url: '/api/knowledge/documents/12/pages/50', image_url: '/api/knowledge/documents/12/pages/50/image' }, { title: '评分报告.pdf', page: 49, quote: '评分原文片段', url: '/api/knowledge/documents/12/pages/49', page_type: 'body', page_type_label: '正文', historical: true, image_url: '/api/knowledge/documents/12/pages/49/image', score_url: '/?page=scores&batch_id=3&project_id=5' }, { title: '课程资料.pdf', page: 5, quote: '课程说明', url: '/api/knowledge/documents/11/pages/5' }], tool_calls: [{ id: 'call-one', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, omitted_text_fields: 1 }, summary: '找到 1 条引用', result: { found: true, citations: [{ page: 48, quote: '第48页原文片段' }] } }, { id: 'call-two', name: 'get_scores', status: 'completed', arguments: { batch_id: 3, omitted_text_fields: 1 }, summary: '返回 1 项评分', result: { project_offset: 0, criterion_offset: 6, next_criterion_offset: 12, results: [{ rows: [{ criterion_id: 'C1', final_score: 8 }] }] } }, { id: 'call-three', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, query: '视觉检测' }, summary: '找到 1 条引用', result: { found: true, citations: [] } }, { id: 'call-four', name: 'search_project_material', status: 'skipped', arguments: { project_id: 1, query: '视觉检测' }, summary: '重复调用已跳过', result: { previous_call_id: 'call-three' } }] }], actions: [], runs: [{ id: 1, status: 'completed', tool_calls: [] }] }, }; let nextId = 8; await page.route('**/api/**', async route => { const request = route.request(); const path = new URL(request.url()).pathname.slice(4); const method = request.method(); calls.push(`${method} ${path}`); const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); if (path === '/conversations' && method === 'GET') return json(Object.values(history).map(d => ({ id: d.id, title: d.title, course_version_id: d.course_version_id, batch_id: d.batch_id, project_id: d.project_id, running: d.runs[0]?.status === 'running' }))); if (path === '/conversations/first-message' && method === 'POST') { const body = request.postDataJSON(); const id = nextId++; history[id] = { id, title: body.content, course_version_id: body.course_version_id || null, batch_id: body.batch_id || null, project_id: body.project_id || null, scope_locked: true, messages: [{ id: id * 10, role: 'user', content: body.content }], actions: [], runs: [{ id, status: 'running', tool_calls: [{ id: `call-${id}`, name: 'list_courses', status: 'running', arguments: {} }] }] }; return json({ conversation_id: id, run_id: id }); } const match = path.match(/^\/conversations\/(\d+)(.*)$/); if (match) { const id = Number(match[1]); const tail = match[2]; if (!history[id]) return json({ detail: '会话不存在' }, 404); if (!tail && method === 'GET') return json(history[id]); if (!tail && method === 'DELETE') { if (history[id].runs[0]?.status === 'running') return json({ detail: '会话正在运行' }, 409); delete history[id]; return json({ deleted: id }); } if (tail === '/scope' && method === 'PUT') { Object.assign(history[id], request.postDataJSON()); return json(history[id]); } if (tail === '/messages/start' && method === 'POST') { const body = request.postDataJSON(); history[id].messages.push({ id: id * 10 + history[id].messages.length, role: 'user', content: body.content }); history[id].runs.unshift({ id: id + 100, status: 'running', tool_calls: [] }); return json({ conversation_id: id, run_id: id + 100 }); } } if (path === '/courses') return json([{ id: 1 }]); if (path === '/courses/1/versions') return json([{ id: 1, version: 1, title: '课程一' }, { id: 2, version: 2, title: '课程二' }]); if (path === '/scoring-batches') return json([{ id: 4, course_version_id: 2, status: 'created', project_ids: [5] }]); if (path === '/projects') return json([{ id: 5, course_version_id: 2, student_no: '1', name: '测试' }]); if (path === '/index-tasks') return json([]); return json({ detail: 'mock not found' }, 404); }); return { calls, history }; } test('blank draft stays local, scope works, history switches and deletion confirms', async ({ page }) => { const state = await mockApi(page); await page.goto('/?page=chat'); await expect(page.getByText('新会话草稿')).toBeVisible(); await expect(page.getByText('历史会话')).toBeVisible(); expect(state.calls.filter(c => c.startsWith('POST /conversations'))).toHaveLength(0); await page.locator('.chat-scope .ant-select').nth(1).click(); await page.getByText('#4 · created').click(); await expect(page.locator('.chat-scope .ant-select').first()).toContainText('课程二'); await page.locator('.chat-scope .ant-select').nth(2).click(); await page.getByText('1 测试').click(); await expect(page.locator('.chat-scope .ant-select').nth(2)).toContainText('1 测试'); await page.locator('.chat-scope .ant-select').first().hover(); await page.locator('.chat-scope .ant-select').first().locator('.ant-select-clear').click(); await expect(page.locator('.chat-scope .ant-select').nth(2)).not.toContainText('1 测试'); await page.getByRole('button', { name: /新\s*建/ }).click(); await expect(page.locator('.chat-scope .ant-select').first()).not.toContainText('课程二'); expect(state.calls.filter(c => c.startsWith('POST /conversations'))).toHaveLength(0); await page.getByText('历史会话').click(); await expect(page.getByText('范围已锁定')).toBeVisible(); await page.getByRole('button', { name: /^删\s*除$/ }).click(); await expect(page.getByText('删除会话?')).toBeVisible(); await page.getByRole('button', { name: /取\s*消/ }).click(); await expect(page.getByText('历史会话').first()).toBeVisible(); await page.getByRole('button', { name: /^删\s*除$/ }).click(); await page.getByRole('button', { name: /删\s*除\s*会\s*话/ }).click(); await expect(page.getByText('新会话草稿')).toBeVisible(); expect(state.calls).toContain('DELETE /conversations/7'); await page.reload(); await expect(page.getByText('新会话草稿')).toBeVisible(); }); test('markdown, citations, tool folding and narrow layout are safe', async ({ page }) => { await mockApi(page); await page.goto('/?page=chat'); await page.getByText('历史会话').click(); await expect(page.getByRole('heading', { name: '标题' })).toBeVisible(); await expect(page.locator('.chat-markdown table')).toBeVisible(); await expect(page.locator('.chat-markdown blockquote')).toBeVisible(); await expect(page.locator('.chat-markdown pre')).toBeVisible(); await expect(page.locator('.chat-markdown img')).toHaveCount(0); await expect(page.getByText('危险链接')).not.toHaveAttribute('href'); await expect(page.getByText('引用 · 5')).toBeVisible(); await expect(page.getByText('资料.pdf', { exact: true })).not.toBeVisible(); await expect(page.getByText('后台进度')).toHaveCount(0); await expect(page.locator('.chat-tools')).toBeVisible(); await expect(page.locator('.chat-tool').first()).not.toBeVisible(); await expect(page.locator('.tool-detail').first()).not.toBeVisible(); await page.locator('.chat-tool-group > summary').click(); await expect(page.locator('.chat-tool').first()).toBeVisible(); await expect(page.locator('.tool-detail').first()).not.toBeVisible(); await page.locator('.chat-tool summary').first().click(); await expect(page.locator('.tool-detail').first()).toBeVisible(); await expect(page.locator('.tool-detail').first()).toContainText('第48页原文片段'); await page.locator('.chat-tool').nth(1).locator('summary').click(); await expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('C1'); await expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('评分项偏移量'); await expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('6'); await expect(page.locator('.chat-tool').first().locator('.tool-detail')).toContainText('历史调用未保存查询词'); await page.locator('.chat-tool').nth(2).locator('summary').click(); await expect(page.locator('.chat-tool').nth(2).locator('.tool-detail')).toContainText('查询词 (query)'); await expect(page.locator('.chat-tool').nth(2).locator('.tool-detail')).toContainText('视觉检测'); await expect(page.locator('.chat-tool').nth(3).locator('summary')).toContainText('已跳过'); await page.locator('.chat-citation-group > summary').click(); await expect(page.locator('.chat-citation-group')).toHaveAttribute('open', ''); await expect(page.getByText('资料.pdf', { exact: true })).toBeVisible(); await expect(page.getByText('第 48 页')).toBeVisible(); const searchCitation = page.locator('.citation-card').filter({ has: page.getByText('资料.pdf', { exact: true }) }); await expect(searchCitation.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/10\/pages\/2$/); await expect(searchCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/10\/pages\/2\/image$/); const migrated = page.locator('.citation-card').filter({ hasText: '综合报告.pdf' }); await expect(migrated.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/48$/); await expect(migrated.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/48\/image$/); await expect(migrated.getByRole('link')).toHaveCount(2); const scoreEvidence = page.locator('.citation-card').filter({ hasText: '已迁移评分证据.pdf' }); await expect(scoreEvidence.getByRole('link', { name: '查看页面文字' })).toBeVisible(); await expect(scoreEvidence.getByRole('link', { name: '查看图像' })).toBeVisible(); const scoreCitation = page.locator('.citation-card').filter({ hasText: '评分报告.pdf' }); await expect(scoreCitation.getByText('正文', { exact: true })).toBeVisible(); await expect(scoreCitation.getByText('历史评分引用', { exact: true })).toBeVisible(); await expect(scoreCitation.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/49$/); await expect(scoreCitation.getByRole('link', { name: '查看评分' })).toHaveAttribute('href', /page=scores&batch_id=3&project_id=5$/); await expect(scoreCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/49\/image$/); const textOnly = page.locator('.citation-card').filter({ hasText: '课程资料.pdf' }); await expect(textOnly.getByRole('link', { name: '查看页面文字' })).toBeVisible(); await expect(textOnly.getByRole('link')).toHaveCount(1); const positions = await page.evaluate(() => { const card = document.querySelector('.chat-assistant .ant-card-body'); return ['.chat-tools', '.chat-markdown', '.chat-citations'].map(selector => Array.from(card?.children || []).findIndex(child => child.matches(selector))); }); expect(positions).toEqual([0, 1, 2]); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator('.ant-layout-sider')).toHaveClass(/ant-layout-sider-zero-width/); await expect(page.locator('.chat-scope .ant-select').first()).toBeVisible(); await page.screenshot({ path: 'test-results/mobile-chat.png', fullPage: true, animations: 'disabled' }); const width = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth); expect(width).toBeLessThanOrEqual(2); }); test('switching sessions leaves runs independent and updates each status', async ({ page }) => { const state = await mockApi(page); await page.goto('/?page=chat'); await page.getByPlaceholder(/例如/).fill('甲'); await page.getByRole('button', { name: /发\s*送/ }).click(); await expect(page.locator('.chat-user .chat-content')).toHaveText('甲'); await expect(page.getByText('生成中').first()).toBeVisible(); await page.getByRole('button', { name: /^删\s*除$/ }).last().click(); await page.getByRole('button', { name: /删\s*除\s*会\s*话/ }).click(); await expect(page.getByText('会话正在运行')).toBeVisible(); await page.getByRole('button', { name: /取\s*消/ }).click(); await page.getByText('历史会话').click(); await expect(page.getByText('旧问题')).toBeVisible(); await page.getByRole('button', { name: /新\s*建/ }).click(); await page.getByPlaceholder(/例如/).fill('乙'); await page.getByRole('button', { name: /发\s*送/ }).click(); await expect(page.locator('.chat-user .chat-content')).toHaveText('乙'); await expect(page.getByText('生成中').first()).toBeVisible(); expect(state.history[8].runs[0].status).toBe('running'); state.history[8].runs[0].status = 'completed'; state.history[8].messages.push({ id: 82, role: 'assistant', content: '回答甲', citations: [], tool_calls: [] }); await page.locator('.chat-session').filter({ has: page.getByText('甲', { exact: true }) }).click(); await expect(page.getByText('回答甲')).toBeVisible(); expect(state.history[9].runs[0].status).toBe('running'); expect(state.calls.filter(c => c === 'POST /conversations/first-message')).toHaveLength(2); });