aps-agent/apps/web/e2e/planner-intake-recovery.spe...

163 lines
10 KiB
TypeScript

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<string, any>;
async function read(page: Page, url: string): Promise<Json> {
const response = await page.request.get(url);
expect(response.ok(), await response.text()).toBeTruthy();
return response.json() as Promise<Json>;
}
async function send(page: Page, text: string): Promise<void> {
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<void> {
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' });
}
});
});
}