import { expect, test, type Locator, type Page } from '@playwright/test'; import { createHash } from 'node:crypto'; import { mkdtempSync, readFileSync } from 'node:fs'; import os from 'node:os'; 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 api = new URL(API_TARGET); if (api.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(api.hostname) || !api.port || ['8000', '8003', '5173'].includes(api.port)) { throw new Error('Recovery acceptance refuses non-isolated endpoints and live development ports.'); } const EXPECTED = JSON.parse(readFileSync(process.env.ROUND87_EXPECTATIONS || fileURLToPath(new URL('../../../tests/fixtures/planning-workbook-acceptance.json', import.meta.url)), 'utf-8')); type Json = Record; async function read(page: Page, url: string): Promise { const response = await page.request.get(url); expect(response.ok(), await response.text()).toBeTruthy(); return response.json() as Promise; } async function send(page: Page, text: string): Promise { await page.locator('.composer textarea').fill(text); await expect(page.locator('.composer button.send')).toBeEnabled(); await page.locator('.composer button.send').click(); } async function inspectFourViews(card: Locator): Promise { for (const [key, label] of [['orders', '订单记录'], ['materials', '产品和材料'], ['routing', '加工步骤'], ['equipment', '设备记录']]) { await card.getByRole('button', { name: `查看${label}明细`, exact: true }).click(); const detail = card.locator('.planning-object-details'); await expect(detail).toBeVisible(); await expect(detail).toContainText(`共 ${EXPECTED.entityCounts[key]} 条`); await expect(detail.locator('th').first()).toBeVisible(); await expect(detail.locator('tbody tr')).toHaveCount(Math.min(10, EXPECTED.entityCounts[key])); const query = (await detail.locator('tbody tr').first().locator('td').first().innerText()).trim(); expect(query).not.toBe('未提供'); const search = detail.getByRole('searchbox'); await search.fill(query); await expect(detail).toContainText('匹配'); expect(await detail.locator('tbody tr').count()).toBeGreaterThan(0); await search.fill('___NO_MATCH___'); await expect(detail).toContainText('没有匹配的记录'); await search.fill(''); if (EXPECTED.entityCounts[key] > 10) { await detail.getByRole('button', { name: '下一页', exact: true }).click(); await expect(detail.locator('.planning-detail-pagination')).toContainText('第 2 /'); await detail.getByRole('button', { name: '上一页', exact: true }).click(); } await detail.getByRole('button', { name: '收起明细', exact: true }).click(); await expect(detail).toHaveCount(0); } } for (const viewport of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'mobile', width: 390, height: 844 }]) { test.describe(`legacy intake recovery (${viewport.name})`, () => { test.use({ viewport }); test.skip(!SOURCE && !REQUIRED, 'Set ROUND87_SOURCE for the authorized isolated recovery acceptance.'); test('four data views, expired confirmation recovery, and legacy sandbox classification', async ({ page }, info) => { test.setTimeout(300_000); page.setDefaultTimeout(25_000); expect(SOURCE, 'ROUND87_REQUIRED needs a source path').toBeTruthy(); expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(EXPECTED.sourceSha256); const health = await read(page, `${API_TARGET}/__e2e__/health`); expect(health).toMatchObject({ isolated: true, sourceUnchanged: true, sourceSha256: EXPECTED.sourceSha256 }); const proxy = await read(page, '/api/__e2e__/health'); expect(proxy.runtimeRoot).toBe(health.runtimeRoot); const login = await page.request.post('/api/auth/login', { data: { username: 'planner', password: 'test', tenantName: 'round87-masterdata-acceptance' } }); expect(login.ok()).toBeTruthy(); const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); try { await page.goto('/'); await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 }); if (viewport.width < 600) await page.locator('.app-menu-icon[title="展开侧栏"]').click(); await page.locator('.workspace-sidebar button[title="新建项目"]').click(); const dialog = page.getByRole('dialog', { name: '新建项目', exact: true }); await dialog.locator('input').first().fill(`资料恢复验收-${viewport.name}-${Date.now()}`); await dialog.getByRole('button', { name: '创建', exact: true }).click(); await expect(dialog).toBeHidden(); await expect.poll(async () => { const workspace = await read(page, '/api/workspace'); return workspace.projects.some((p: Json) => p.id === workspace.activeProjectId && p.name.startsWith('资料恢复验收-')); }).toBe(true); const workspace = await read(page, '/api/workspace'); const projectId = workspace.activeProjectId; const sessionId = workspace.activeSessionId; if (viewport.width < 600) { const backdrop = page.getByRole('button', { name: '关闭侧栏', exact: true }); if (await backdrop.isVisible()) { const box = await backdrop.boundingBox(); await backdrop.click({ position: { x: box!.width - 8, y: 100 } }); } } const seeded = await page.request.post('/api/__e2e__/seed-legacy-intake', { data: { projectId, sessionId } }); expect(seeded.ok(), await seeded.text()).toBeTruthy(); const legacy = await seeded.json() as Json; expect(legacy.formalOrderNos.sort()).toEqual([...EXPECTED.formalOrderNos, ...EXPECTED.sandboxOrderNos].sort()); await page.locator('.composer-file-input').setInputFiles(SOURCE!); await send(page, '分析一下数据文件'); const cards = page.locator('.planning-data-card'); const first = cards.last(); await expect(first).toContainText('排产资料核对', { timeout: 90_000 }); await expect(first).toContainText(EXPECTED.sandboxOrderNos[0]); await expect(first).toContainText('确认前'); await inspectFourViews(first); const pending = page.locator('.confirm-card').filter({ hasText: '核对旧记录并采用资料' }).last(); await expect(pending.getByRole('button', { name: '确认采用资料', exact: true })).toBeEnabled(); const pendingApi = await read(page, '/api/gov/pending'); const pendingRows = pendingApi.pending; expect(Array.isArray(pendingRows)).toBe(true); const action = pendingRows.find((row: Json) => row.sessionId === sessionId && row.action === 'import.commit'); expect(action, 'real pending adoption confirmation').toBeTruthy(); const consumed = await page.request.post('/api/actions/confirm', { data: { sessionId, confirmId: action.confirmId, approve: false } }); expect(consumed.ok()).toBeTruthy(); const expiredResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/actions/confirm' && response.request().method() === 'POST'); await pending.getByRole('button', { name: '确认采用资料', exact: true }).click(); const expired = await (await expiredResponse).json() as Json; expect(expired).toMatchObject({ confirmationHandled: true, errorCode: 'CONFIRMATION_EXPIRED' }); await expect(pending.getByRole('button', { name: '确认采用资料', exact: true })).toBeDisabled(); const recheck = pending.getByRole('button', { name: '重新检查资料', exact: true }); await expect(recheck).toBeVisible(); const before = await cards.count(); await recheck.click(); await expect.poll(() => cards.count()).toBeGreaterThan(before); const latest = cards.last(); await expect(latest).toContainText('排产资料核对'); const confirmation = page.waitForResponse(response => new URL(response.url()).pathname === '/api/actions/confirm' && response.request().method() === 'POST'); await page.locator('.confirm-card').last().getByRole('button', { name: '确认采用资料', exact: true }).click(); const result = await (await confirmation).json() as Json; expect(result.refresh).toBe(true); await expect.poll(async () => (await read(page, '/api/flex/world')).orders.map((r: Json) => r.orderNo).sort()).toEqual([...EXPECTED.formalOrderNos].sort()); expect((await read(page, '/api/master')).materials).toHaveLength(EXPECTED.entityCounts.materials); await send(page, '根据这些数据排产'); await expect(page.locator('.bubble-md').last()).not.toBeEmpty({ timeout: 90_000 }); await expect(page.locator('.flex-block-title').last()).toContainText('试排待补资料'); await expect.poll(async () => (await read(page, '/api/flex/world')).latestVersion?.woCount).toBe(0); await send(page, '分析一下数据文件'); await expect.poll(() => cards.count()).toBeGreaterThan(before + 1); await inspectFourViews(cards.last()); await inspectFourViews(cards.first()); // History remains a usable source snapshot. const widths = await page.evaluate(() => ({ viewport: innerWidth, document: document.documentElement.scrollWidth })); expect(widths.document).toBeLessThanOrEqual(widths.viewport + 1); const image = path.join(mkdtempSync(path.join(os.tmpdir(), 'aps-intake-recovery-')), `${viewport.name}.png`); await page.screenshot({ path: image, fullPage: true }); await info.attach('recovery-complete', { path: image, contentType: 'image/png' }); expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(EXPECTED.sourceSha256); expect(errors).toEqual([]); } finally { await info.attach('browser-errors', { body: JSON.stringify(errors, null, 2), contentType: 'application/json' }); } }); }); }