aps-agent/apps/web/e2e/planning-history-retry.spec.ts

129 lines
7.6 KiB
TypeScript

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<string, any>;
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<Json[]> => {
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' });
}
});
});