120 lines
7.1 KiB
TypeScript
120 lines
7.1 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('Project 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('project creation recovers from one failed workspace refresh', () => {
|
|
test.use({ viewport: { width: 1440, height: 900 } });
|
|
test.skip(!SOURCE && !REQUIRED, 'Opt in using the authorized workbook.');
|
|
test('retry loads the already-created project without another POST and retains immediate attachment analysis', async ({ page }, info) => {
|
|
test.setTimeout(180_000);
|
|
page.setDefaultTimeout(25_000);
|
|
expect(SOURCE, 'Required acceptance needs ROUND87_SOURCE').toBeTruthy();
|
|
expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(EXPECTED.sourceSha256);
|
|
const directResponse = await page.request.get(`${API_TARGET}/__e2e__/health`);
|
|
expect(directResponse.ok()).toBeTruthy();
|
|
const direct = await directResponse.json();
|
|
expect(direct).toMatchObject({ isolated: true, sourceUnchanged: true, sourceSha256: EXPECTED.sourceSha256 });
|
|
const proxied = await page.request.get('/api/__e2e__/health');
|
|
expect(proxied.ok()).toBeTruthy();
|
|
expect((await proxied.json()).runtimeRoot).toBe(direct.runtimeRoot);
|
|
const login = await page.request.post('/api/auth/login', { data: { username: 'planner', password: 'test' } });
|
|
expect(login.ok()).toBeTruthy();
|
|
await page.goto('/');
|
|
await expect(page.locator('section[data-session-ready="true"]')).toBeVisible({ timeout: 60_000 });
|
|
|
|
let createPosts = 0;
|
|
let failuresInjected = 0;
|
|
let abortNextWorkspace = false;
|
|
let created: Json | undefined;
|
|
const pageErrors: string[] = [];
|
|
const requestFailures: string[] = [];
|
|
page.on('pageerror', error => pageErrors.push(error.message));
|
|
page.on('requestfailed', request => requestFailures.push(new URL(request.url()).pathname));
|
|
await page.route('**/api/projects', async route => {
|
|
if (route.request().method() !== 'POST') return route.continue();
|
|
createPosts += 1;
|
|
// Forward the exact response from the real service. Only the following
|
|
// workspace GET is failed; project/session identities are never mocked.
|
|
const response = await route.fetch();
|
|
expect(response.ok()).toBeTruthy();
|
|
created = await response.json() as Json;
|
|
abortNextWorkspace = true;
|
|
await route.fulfill({ response });
|
|
});
|
|
await page.route('**/api/workspace', async route => {
|
|
if (route.request().method() === 'GET' && abortNextWorkspace && failuresInjected === 0) {
|
|
abortNextWorkspace = false;
|
|
failuresInjected += 1;
|
|
return route.abort('failed');
|
|
}
|
|
await route.continue();
|
|
});
|
|
try {
|
|
await page.locator('.workspace-sidebar button[title="新建项目"]').click();
|
|
const dialog = page.getByRole('dialog', { name: '新建项目', exact: true });
|
|
const projectName = `项目重试验收-${Date.now()}`;
|
|
await dialog.locator('input').first().fill(projectName);
|
|
await dialog.getByRole('button', { name: '创建', exact: true }).click();
|
|
await expect(dialog.getByRole('alert')).toBeVisible();
|
|
await expect(dialog.getByRole('button', { name: '重试', exact: true })).toBeEnabled();
|
|
expect(failuresInjected).toBe(1);
|
|
expect(createPosts).toBe(1);
|
|
expect(created?.project.id).toBeTruthy();
|
|
expect(created?.session.id).toBeTruthy();
|
|
const workspaceAfterFailure = await page.request.get('/api/workspace');
|
|
expect(workspaceAfterFailure.ok()).toBeTruthy();
|
|
const beforeRetry = await workspaceAfterFailure.json() as Json;
|
|
expect(beforeRetry.projects.filter((project: Json) => project.name === projectName)).toHaveLength(1);
|
|
const projectId = created!.project.id;
|
|
const sessionId = created!.session.id;
|
|
await dialog.getByRole('button', { name: '重试', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
const chat = page.locator(`section[data-session-id="${sessionId}"]`);
|
|
await expect(chat).toHaveAttribute('data-session-ready', 'true');
|
|
expect(createPosts, 'Retry must not create another project').toBe(1);
|
|
expect(failuresInjected, 'Only one failed network request is injected').toBe(1);
|
|
const afterRetry = await (await page.request.get('/api/workspace')).json() as Json;
|
|
expect(afterRetry.activeProjectId).toBe(projectId);
|
|
expect(afterRetry.activeSessionId).toBe(sessionId);
|
|
expect(afterRetry.projects.filter((project: Json) => project.name === projectName)).toHaveLength(1);
|
|
expect(afterRetry.sessions.filter((session: Json) => session.projectId === projectId)).toHaveLength(1);
|
|
await chat.locator('.composer-file-input').setInputFiles(SOURCE!);
|
|
await expect(chat.locator('.material-strip')).toContainText(path.basename(SOURCE!));
|
|
const upload = page.waitForResponse(response => new URL(response.url()).pathname === `/api/projects/${projectId}/files/upload`
|
|
&& response.request().method() === 'POST');
|
|
const analysis = page.waitForRequest(request => new URL(request.url()).pathname === '/api/chat'
|
|
&& request.method() === 'POST' && request.postDataJSON().text === '分析一下数据文件');
|
|
await chat.locator('.composer textarea').fill('分析一下数据文件');
|
|
await chat.locator('.composer button.send').click();
|
|
const uploaded = await upload;
|
|
expect(uploaded.ok(), await uploaded.text()).toBeTruthy();
|
|
expect((await uploaded.json() as Json).saved).toHaveLength(1);
|
|
expect((await analysis).postDataJSON()).toMatchObject({ projectId, sessionId });
|
|
const card = chat.locator('.planning-data-card').last();
|
|
await expect(card).toContainText('排产资料核对', { timeout: 90_000 });
|
|
await expect(card.getByRole('button', { name: '查看产品和材料明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.materials));
|
|
await expect(card.getByRole('button', { name: '开始排产', exact: true })).toBeVisible();
|
|
expect(pageErrors).toEqual([]);
|
|
expect(requestFailures).toEqual(['/api/workspace']);
|
|
expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(EXPECTED.sourceSha256);
|
|
} finally {
|
|
await info.attach('network-retry-evidence', { body: JSON.stringify({ createPosts, failuresInjected, pageErrors, requestFailures }), contentType: 'application/json' });
|
|
await page.unrouteAll({ behavior: 'wait' });
|
|
}
|
|
});
|
|
});
|