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; 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' }); } }); });