import { expect, test } from '@playwright/test'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const SOURCE = process.env.ROUND87_SOURCE; const REQUIRED = process.env.ROUND87_REQUIRED === '1'; const API_TARGET = process.env.E2E_API_TARGET || 'http://127.0.0.1:18787'; const target = new URL(API_TARGET); if (target.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(target.hostname) || !target.port || ['8000', '8003', '5173'].includes(target.port)) { throw new Error('History retry acceptance requires a dedicated loopback backend.'); } const EXPECTED = JSON.parse(readFileSync(process.env.ROUND87_EXPECTATIONS || fileURLToPath(new URL('../../../tests/fixtures/planning-workbook-acceptance.json', import.meta.url)), 'utf8')); type Json = Record; test.describe('history load failure preserves saved planning records', () => { test.use({ viewport: { width: 1440, height: 900 } }); test.skip(!SOURCE && !REQUIRED, 'Opt in with the authorized source workbook.'); test('failed history GET cannot save an empty conversation and retry restores the real scheduling entry', async ({ page }, info) => { test.setTimeout(180_000); page.setDefaultTimeout(25_000); expect(SOURCE).toBeTruthy(); expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(EXPECTED.sourceSha256); const direct = await page.request.get(`${API_TARGET}/__e2e__/health`); expect(direct.ok()).toBeTruthy(); const health = await direct.json() as Json; expect(health).toMatchObject({ isolated: true, sourceUnchanged: true, sourceSha256: EXPECTED.sourceSha256 }); const proxy = await page.request.get('/api/__e2e__/health'); expect(proxy.ok()).toBeTruthy(); expect((await proxy.json()).runtimeRoot).toBe(health.runtimeRoot); expect((await page.request.post('/api/auth/login', { data: { username: 'planner', password: 'test' } })).ok()).toBeTruthy(); const creation = await page.request.post('/api/projects', { data: { name: `会话读取恢复验收-${Date.now()}` } }); expect(creation.ok()).toBeTruthy(); const created = await creation.json() as Json; const sessionId = created.session.id; const projectId = created.project.id; const messagesPath = `/api/sessions/${sessionId}/messages`; await page.goto('/'); const chat = page.locator(`section[data-session-id="${sessionId}"]`); await expect(chat).toHaveAttribute('data-session-ready', 'true', { timeout: 60_000 }); await chat.locator('.composer-file-input').setInputFiles(SOURCE!); await expect(chat.locator('.material-strip')).toContainText(path.basename(SOURCE!)); await chat.locator('.composer textarea').fill('分析一下数据文件'); await chat.locator('.composer button.send').click(); const card = chat.locator('.planning-data-card').last(); await expect(card).toContainText('排产资料核对', { timeout: 90_000 }); const confirmation = page.waitForResponse(response => new URL(response.url()).pathname === '/api/actions/confirm' && response.request().method() === 'POST'); await chat.locator('.confirm-card').last().getByRole('button', { name: '确认采用资料', exact: true }).click(); expect((await (await confirmation).json() as Json).refresh).toBe(true); await expect(card.getByRole('button', { name: '开始排产', exact: true })).toBeEnabled(); const savedMessages = async (): Promise => { const response = await page.request.get(messagesPath); expect(response.ok()).toBeTruthy(); return (await response.json() as Json).messages; }; await expect.poll(async () => (await savedMessages()).flatMap(message => message.blocks || []) .some(block => block.type === 'confirm-card' && block.props.handled === true)).toBe(true); const originalHistory = await savedMessages(); const sourceCard = originalHistory.flatMap(message => message.blocks || []).find(block => block.type === 'folder-pack'); expect(sourceCard).toBeTruthy(); expect(sourceCard.props.source.sha256).toBe(EXPECTED.sourceSha256); let readAttempts = 0; const writes: Json[] = []; page.on('request', request => { if (new URL(request.url()).pathname === messagesPath && request.method() === 'PUT') { writes.push(request.postDataJSON()); } }); await page.route(`**${messagesPath}`, async route => { if (route.request().method() === 'GET') { readAttempts += 1; if (readAttempts === 1) return route.abort('failed'); } await route.continue(); }); try { await page.reload(); await expect(chat).toHaveAttribute('data-session-ready', 'false'); await expect(chat.getByRole('alert')).toBeVisible(); await expect(chat.locator('.composer textarea')).toBeDisabled(); await expect(chat.locator('.composer button.send')).toBeDisabled(); const retry = chat.getByRole('button', { name: '重新读取会话', exact: true }); await expect(retry).toBeEnabled(); expect(readAttempts).toBe(1); expect(writes, 'Failed history loading must never persist an empty conversation').toEqual([]); expect(await savedMessages()).toEqual(originalHistory); const retried = page.waitForResponse(response => new URL(response.url()).pathname === messagesPath && response.request().method() === 'GET'); await retry.click(); expect((await retried).ok()).toBeTruthy(); await expect(chat).toHaveAttribute('data-session-ready', 'true'); await expect(retry).toBeHidden(); await expect(card).toContainText('排产资料核对'); const start = card.getByRole('button', { name: '开始排产', exact: true }); await expect(start).toBeEnabled(); await card.getByRole('button', { name: '查看产品和材料明细' }).click(); await expect(card.locator('.planning-object-details')).toContainText(`共 ${EXPECTED.entityCounts.materials} 条`); await card.getByRole('button', { name: '收起明细', exact: true }).click(); const scheduling = page.waitForResponse(response => new URL(response.url()).pathname === '/api/chat' && response.request().method() === 'POST' && response.request().postDataJSON().text === '立即排产'); await start.click(); const actual = await scheduling; expect(actual.ok()).toBeTruthy(); expect(actual.request().postDataJSON()).toMatchObject({ projectId, sessionId }); await actual.finished(); const events = (await actual.text()).split(/\r?\n\r?\n/).filter(frame => frame.startsWith('data: ')) .map(frame => JSON.parse(frame.slice(6)) as Json); const result = events.find(event => event.type === 'block' && event.block?.type === 'flex-schedule'); expect(result?.block.props.solveStatus).toBe('BLOCKED'); await expect(chat.locator('.flex-block').last()).toContainText('试排待补资料'); expect(readAttempts).toBe(2); expect(writes.every(write => Array.isArray(write.messages) && write.messages.length >= originalHistory.length)).toBe(true); expect((await savedMessages()).flatMap(message => message.blocks || []).some(block => block.blockId === sourceCard.blockId)).toBe(true); expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(EXPECTED.sourceSha256); } finally { await info.attach('history-preservation-evidence', { body: JSON.stringify({ readAttempts, originalMessageCount: originalHistory.length, savedMessageCounts: writes.map(write => write.messages?.length) }), contentType: 'application/json' }); await page.unrouteAll({ behavior: 'wait' }); } }); });