287 lines
18 KiB
TypeScript
287 lines
18 KiB
TypeScript
import { expect, test, type Page, type TestInfo } 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';
|
|
|
|
// Explicit opt-in to the exact source; ordinary E2E suites do not need a local
|
|
// customer workbook. The authorized Round 87 run MUST set ROUND87_SOURCE.
|
|
const SOURCE = process.env.ROUND87_SOURCE;
|
|
const API_TARGET = process.env.E2E_API_TARGET || 'http://127.0.0.1:18787';
|
|
const REQUIRED = process.env.ROUND87_REQUIRED === '1';
|
|
const expectationPath = process.env.ROUND87_EXPECTATIONS
|
|
|| fileURLToPath(new URL('../../../tests/fixtures/planning-workbook-acceptance.json', import.meta.url));
|
|
const EXPECTED = JSON.parse(readFileSync(expectationPath, 'utf-8'));
|
|
if (EXPECTED.schemaVersion !== 1 || typeof EXPECTED.sourceSha256 !== 'string') {
|
|
throw new Error('Invalid independent workbook acceptance manifest');
|
|
}
|
|
const SOURCE_HASH = EXPECTED.sourceSha256;
|
|
const apiUrl = new URL(API_TARGET);
|
|
if (!['127.0.0.1', 'localhost', '[::1]'].includes(apiUrl.hostname)
|
|
|| apiUrl.protocol !== 'http:' || !apiUrl.port
|
|
|| ['8000', '8003', '5173'].includes(apiUrl.port)) {
|
|
throw new Error('Round 87 browser acceptance requires an isolated loopback backend; live ports are refused.');
|
|
}
|
|
|
|
type Json = Record<string, any>;
|
|
|
|
async function browserApi(page: Page, apiPath: string): Promise<Json> {
|
|
const response = await page.request.get(apiPath);
|
|
expect(response.ok(), `GET ${apiPath}: ${await response.text()}`).toBeTruthy();
|
|
return await response.json() as Json;
|
|
}
|
|
|
|
async function checkIsolation(page: Page): Promise<void> {
|
|
const direct = await page.request.get(`${API_TARGET}/__e2e__/health`);
|
|
expect(direct.ok(), 'start tests/e2e/round87_masterdata_server.py first').toBeTruthy();
|
|
const backend = await direct.json() as Json;
|
|
expect(backend).toMatchObject({ isolated: true, sourceSha256: SOURCE_HASH,
|
|
sourceUnchanged: true, productionCode: true });
|
|
// This alias is implemented by the isolated test host, never by production.
|
|
// It proves Vite is pointing at the same host BEFORE any login or UI writes.
|
|
const proxied = await page.request.get('/api/__e2e__/health');
|
|
expect(proxied.ok(), 'frontend proxy must target the isolated Round 87 host').toBeTruthy();
|
|
const frontendBackend = await proxied.json() as Json;
|
|
expect(frontendBackend).toMatchObject({ isolated: true, sourceSha256: SOURCE_HASH,
|
|
sourceUnchanged: true, runtimeRoot: backend.runtimeRoot });
|
|
}
|
|
|
|
async function assertNoHorizontalOverflow(page: Page, context: string): Promise<void> {
|
|
const widths = await page.evaluate(() => ({
|
|
viewport: window.innerWidth,
|
|
document: document.documentElement.scrollWidth,
|
|
body: document.body.scrollWidth,
|
|
}));
|
|
expect(widths.document, `${context}: document spills horizontally`).toBeLessThanOrEqual(widths.viewport + 1);
|
|
expect(widths.body, `${context}: body spills horizontally`).toBeLessThanOrEqual(widths.viewport + 1);
|
|
}
|
|
|
|
async function screenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
|
const output = path.join(mkdtempSync(path.join(os.tmpdir(), 'aps-round87-ui-')), `${name}.png`);
|
|
await page.screenshot({ path: output, fullPage: true });
|
|
await testInfo.attach(name, { path: output, contentType: 'image/png' });
|
|
}
|
|
|
|
async function createProjectThroughUi(page: Page, mobile: boolean, projectName: string): Promise<string> {
|
|
if (mobile) await page.locator('.app-menu-icon[title="展开侧栏"]').click();
|
|
await page.locator('.workspace-sidebar:not(.guest-sidebar) button[title="新建项目"]').click();
|
|
const dialog = page.getByRole('dialog', { name: '新建项目', exact: true });
|
|
await expect(dialog).toBeVisible();
|
|
await dialog.locator('input').first().fill(projectName);
|
|
// No source directory: the file must reach this project through the browser.
|
|
await expect(dialog.locator('input').nth(1)).toHaveValue('');
|
|
const createdResponse = page.waitForResponse(response =>
|
|
new URL(response.url()).pathname === '/api/projects' && response.request().method() === 'POST');
|
|
await dialog.getByRole('button', { name: '创建', exact: true }).click();
|
|
const created = await createdResponse;
|
|
expect(created.ok(), await created.text()).toBeTruthy();
|
|
const body = await created.json() as Json;
|
|
const projectId = body.project.id as string;
|
|
expect(body.project.name).toBe(projectName);
|
|
await expect(dialog).toBeHidden();
|
|
await expect.poll(async () => (await browserApi(page, '/api/workspace')).activeProjectId).toBe(projectId);
|
|
if (mobile) {
|
|
const backdrop = page.getByRole('button', { name: '关闭侧栏', exact: true });
|
|
if (await backdrop.isVisible()) {
|
|
const box = await backdrop.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
// Backdrop covers the viewport; its centre is under the open sidebar on
|
|
// small screens. Click the actually exposed edge, as a user would.
|
|
await backdrop.click({ position: { x: box!.width - 8, y: Math.min(100, box!.height - 8) } });
|
|
}
|
|
}
|
|
await expect(page.locator('.composer textarea')).toBeVisible();
|
|
return projectId;
|
|
}
|
|
|
|
async function assertAdoptedData(page: Page): Promise<Json> {
|
|
const master = await browserApi(page, '/api/master');
|
|
const flex = await browserApi(page, '/api/flex/world');
|
|
expect(master.materials).toHaveLength(EXPECTED.entityCounts.materials);
|
|
expect(master.equipmentOptions).toHaveLength(EXPECTED.entityCounts.equipment);
|
|
expect(master.workstations.length).toBeGreaterThan(0);
|
|
expect(master.equipment).toHaveLength(EXPECTED.entityCounts.equipment);
|
|
expect(Array.isArray(master.calendarTemplates)).toBeTruthy();
|
|
expect(Array.isArray(master.calendarHolidays)).toBeTruthy();
|
|
expect(Array.isArray(master.masterdataVersions)).toBeTruthy();
|
|
expect(master.routings.reduce((n: number, route: Json) => n + route.steps.length, 0)).toBe(EXPECTED.entityCounts.routing);
|
|
expect(flex.materials).toHaveLength(EXPECTED.entityCounts.materials);
|
|
expect(flex.equipment).toHaveLength(EXPECTED.entityCounts.equipment);
|
|
expect(flex.orders.map((row: Json) => row.orderNo).sort()).toEqual([...EXPECTED.formalOrderNos].sort());
|
|
expect(flex.materials.reduce((n: number, row: Json) => n + Number(row.inTransit || 0), 0)).toBe(EXPECTED.inventory.inTransit);
|
|
expect(flex.latestVersion).toBeNull(); // Adopting data must not silently schedule it.
|
|
return master;
|
|
}
|
|
|
|
for (const viewport of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'mobile', width: 390, height: 844 }]) {
|
|
test.describe(`Round 87 planner master data (${viewport.name})`, () => {
|
|
test.use({ viewport: { width: viewport.width, height: viewport.height } });
|
|
test.skip(!SOURCE && !REQUIRED, 'Set ROUND87_SOURCE to the explicitly authorized original workbook.');
|
|
|
|
test('upload full workbook, review/adopt, reload, edit imported routing and verify flexible input', async ({ page }, testInfo) => {
|
|
test.setTimeout(300_000);
|
|
page.setDefaultTimeout(25_000);
|
|
expect(SOURCE, 'ROUND87_SOURCE is required for explicit acceptance').toBeTruthy();
|
|
const raw = readFileSync(SOURCE!);
|
|
expect(createHash('sha256').update(raw).digest('hex')).toBe(SOURCE_HASH);
|
|
await checkIsolation(page);
|
|
const login = await page.request.post('/api/auth/login', {
|
|
data: { tenantName: 'round87-masterdata-acceptance', username: 'planner', password: 'test' },
|
|
});
|
|
expect(login.ok(), await login.text()).toBeTruthy();
|
|
|
|
const errors: string[] = [];
|
|
page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
|
|
page.on('console', message => {
|
|
if (message.type() === 'error') errors.push(`console: ${message.text()}`);
|
|
});
|
|
page.on('response', response => {
|
|
const responsePath = new URL(response.url()).pathname;
|
|
if (response.status() >= 500 && responsePath.startsWith('/api/')) {
|
|
errors.push(`HTTP ${response.status()}: ${response.url()}`);
|
|
}
|
|
if (response.status() === 401) errors.push(`HTTP 401: ${response.url()}`);
|
|
if (responsePath.startsWith('/api/masterdata')) {
|
|
errors.push(`legacy masterdata request: ${response.url()}`);
|
|
}
|
|
});
|
|
try {
|
|
await page.goto('/');
|
|
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
|
|
const mobile = viewport.width < 600;
|
|
const projectName = `${EXPECTED.browser.projectNamePrefix}-${viewport.name}-${Date.now()}`;
|
|
const projectId = await createProjectThroughUi(page, mobile, projectName);
|
|
|
|
const empty = await browserApi(page, '/api/master');
|
|
expect(empty.materials).toHaveLength(0);
|
|
await page.locator('.composer-file-input').setInputFiles(SOURCE!);
|
|
await expect(page.locator('.material-strip')).toContainText(path.basename(SOURCE!));
|
|
await page.locator('.composer textarea').fill('分析一下数据文件');
|
|
const uploadResponse = page.waitForResponse(response =>
|
|
new URL(response.url()).pathname === `/api/projects/${projectId}/files/upload`
|
|
&& response.request().method() === 'POST');
|
|
await page.locator('.composer button.send').click();
|
|
const uploaded = await uploadResponse;
|
|
expect(uploaded.ok(), await uploaded.text()).toBeTruthy();
|
|
const upload = await uploaded.json() as Json;
|
|
expect(upload.errors || []).toHaveLength(0);
|
|
expect(upload.saved).toHaveLength(1);
|
|
|
|
const card = page.locator('.planning-data-card').filter({ hasText: '排产资料核对' }).last();
|
|
await expect(card).toBeVisible({ timeout: 90_000 });
|
|
const metrics = card.locator('.planning-data-counts');
|
|
await expect(metrics.getByRole('button', { name: '查看产品和物料明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.materials));
|
|
await expect(metrics.getByRole('button', { name: '查看订单记录明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.orders));
|
|
await expect(metrics.getByRole('button', { name: '查看加工步骤明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.routing));
|
|
await expect(metrics.getByRole('button', { name: '查看设备记录明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.equipment));
|
|
// 首屏只给结论、关键数和前三条关注项,其余明细在折叠层。
|
|
await expect(card.locator('.planning-data-conclusion')).toHaveText('资料已读取,确认采用后才会写入项目主数据。');
|
|
await expect(card).toContainText(EXPECTED.browser.missingSkillLabel);
|
|
await expect(card).toContainText('尚未写入主数据');
|
|
await expect(card.getByRole('button', { name: '确认数据,生成方案' })).toHaveCount(0);
|
|
await expect(card.getByRole('button', { name: '开始排产' })).toBeDisabled();
|
|
await expect(page.locator('.planning-progress')).toContainText('等待你确认');
|
|
await expect(page.locator('.chips')).toHaveCount(0);
|
|
expect((await browserApi(page, '/api/master')).materials).toHaveLength(0);
|
|
await card.getByText('查看文件、订单与检查明细', { exact: true }).click();
|
|
await expect(card).toContainText(`人员 ${EXPECTED.entityCounts.personnel} 名`);
|
|
await expect(card).toContainText(`在制任务 ${EXPECTED.entityCounts.wip} 条`);
|
|
await expect(card).toContainText(`待评估插单 ${EXPECTED.entityCounts.sandboxOrders} 张`);
|
|
await expect(card).toContainText(EXPECTED.unknownWip.equipmentCode);
|
|
await expect(card).toContainText(SOURCE_HASH);
|
|
await expect(card.locator('.planning-file li')).toHaveCount(Object.keys(EXPECTED.sheets).length);
|
|
await card.getByText('查看文件、订单与检查明细', { exact: true }).click();
|
|
await card.scrollIntoViewIfNeeded();
|
|
const cardBox = await card.boundingBox();
|
|
expect(cardBox).not.toBeNull();
|
|
expect(cardBox!.x).toBeGreaterThanOrEqual(-1);
|
|
expect(cardBox!.x + cardBox!.width).toBeLessThanOrEqual(viewport.width + 1);
|
|
await assertNoHorizontalOverflow(page, 'workbook review');
|
|
await screenshot(page, testInfo, `${viewport.name}-review`);
|
|
|
|
const adoptionResponse = page.waitForResponse(response =>
|
|
new URL(response.url()).pathname === '/api/actions/confirm' && response.request().method() === 'POST');
|
|
await page.getByRole('button', { name: '确认采用资料', exact: true }).last().click();
|
|
const adopted = await adoptionResponse;
|
|
expect(adopted.ok(), await adopted.text()).toBeTruthy();
|
|
expect((await adopted.json() as Json).refresh).toBe(true);
|
|
await expect.poll(async () => (await browserApi(page, '/api/master')).materials.length).toBe(EXPECTED.entityCounts.materials);
|
|
await page.reload();
|
|
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
|
|
const master = await assertAdoptedData(page);
|
|
|
|
const route = master.routings.find((row: Json) => row.productCode === EXPECTED.browser.productCode) as Json;
|
|
expect(route).toBeTruthy();
|
|
const step = route.steps.find((row: Json) => row.sequenceNo === EXPECTED.browser.stepSeq) as Json;
|
|
const initialTimes = await browserApi(page, '/api/times');
|
|
const initialTime = initialTimes.rows.find((row: Json) => row.productCode === route.productCode && row.seq === EXPECTED.browser.stepSeq);
|
|
expect(initialTime.stdMin).toBe(EXPECTED.browser.initialMinutes);
|
|
const masterButtons = page.locator('.right-rail:not(.guest-right-rail) button[data-tip="主数据"]');
|
|
await expect(masterButtons).toHaveCount(1);
|
|
await expect(page.locator('.right-rail:not(.guest-right-rail) button[data-tip="主数据维护"]')).toHaveCount(0);
|
|
await masterButtons.click();
|
|
const panel = page.locator('.master-panel');
|
|
await expect(panel).toBeVisible();
|
|
await expect(panel.locator('.panel-head')).toContainText(`${EXPECTED.entityCounts.materials} 物料`);
|
|
await panel.getByRole('tab', { name: '工位·版本·日历', exact: true }).click();
|
|
await expect(panel.getByTestId('station-version-calendar')).toBeVisible();
|
|
await expect(panel.getByRole('tab', { name: '工位/设备', exact: true })).toBeVisible();
|
|
await expect(panel.getByRole('tab', { name: '版本发布/回滚', exact: true })).toBeVisible();
|
|
await expect(panel.getByRole('tab', { name: '班次日历', exact: true })).toBeVisible();
|
|
await expect(panel.getByTestId('master-station-list').locator('tbody tr')).not.toHaveCount(0);
|
|
await expect(panel.getByTestId('master-equipment-list').locator('tbody tr')).not.toHaveCount(0);
|
|
await assertNoHorizontalOverflow(page, 'merged master data panel');
|
|
await panel.getByRole('tab', { name: '工艺模型', exact: true }).click();
|
|
await panel.getByRole('tab', { name: '工艺路线', exact: true }).click();
|
|
await panel.locator('tr[data-row-key]').filter({ has: page.getByRole('cell', { name: route.productName, exact: true }) }).first().click();
|
|
const stepRow = panel.locator('tr[data-row-key]').filter({
|
|
has: page.getByRole('cell', { name: step.operationName, exact: true }),
|
|
}).filter({ has: page.getByRole('button', { name: '编辑', exact: true }) }).first();
|
|
await stepRow.getByRole('button', { name: '编辑', exact: true }).click();
|
|
const drawer = page.getByRole('dialog').filter({ hasText: '编辑步骤' });
|
|
await expect(drawer).toBeVisible();
|
|
const newMinutes = viewport.name === 'desktop' ? 7.25 : 8.5;
|
|
await drawer.locator('.ant-form-item').filter({ hasText: '单件(分)' }).getByRole('spinbutton').fill(String(newMinutes));
|
|
const stagedResponse = page.waitForResponse(response =>
|
|
new URL(response.url()).pathname === '/api/master/stage' && response.request().method() === 'POST');
|
|
await drawer.getByRole('button', { name: '保存并确认', exact: true }).click();
|
|
const staged = await stagedResponse;
|
|
expect(staged.ok(), await staged.text()).toBeTruthy();
|
|
expect(staged.request().postDataJSON()).toMatchObject({ action: 'master.routing.upsert',
|
|
payload: { stepId: step.id, runTimePerUnit: newMinutes } });
|
|
expect((await browserApi(page, '/api/times')).rows.find((row: Json) =>
|
|
row.productCode === route.productCode && row.seq === EXPECTED.browser.stepSeq).stdMin).toBe(EXPECTED.browser.initialMinutes);
|
|
const modal = page.getByRole('dialog').filter({ has: page.getByRole('button', { name: '批准执行', exact: true }) });
|
|
await expect(modal).toBeVisible();
|
|
await assertNoHorizontalOverflow(page, 'routing change confirmation');
|
|
await screenshot(page, testInfo, `${viewport.name}-routing-confirm`);
|
|
const confirmedResponse = page.waitForResponse(response =>
|
|
new URL(response.url()).pathname === '/api/actions/confirm' && response.request().method() === 'POST');
|
|
await modal.getByRole('button', { name: '批准执行', exact: true }).click();
|
|
const confirmed = await confirmedResponse;
|
|
expect(confirmed.ok(), await confirmed.text()).toBeTruthy();
|
|
expect((await confirmed.json() as Json).refresh).toBe(true);
|
|
await expect(modal).toBeHidden();
|
|
await expect.poll(async () => (await browserApi(page, '/api/times')).rows.find((row: Json) =>
|
|
row.productCode === route.productCode && row.seq === EXPECTED.browser.stepSeq).stdMin).toBe(newMinutes);
|
|
|
|
await page.reload();
|
|
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
|
|
const restored = await assertAdoptedData(page);
|
|
const restoredStep = restored.routings.find((row: Json) => row.id === route.id).steps.find((row: Json) => row.id === step.id);
|
|
expect(restoredStep.runTimePerUnit).toBe(newMinutes);
|
|
expect((await browserApi(page, '/api/times')).rows.find((row: Json) =>
|
|
row.productCode === route.productCode && row.seq === EXPECTED.browser.stepSeq).stdMin).toBe(newMinutes);
|
|
await assertNoHorizontalOverflow(page, 'restored planner workspace');
|
|
await screenshot(page, testInfo, `${viewport.name}-restored`);
|
|
await checkIsolation(page);
|
|
expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(SOURCE_HASH);
|
|
expect(errors, 'browser errors collected during real business interactions').toEqual([]);
|
|
} finally {
|
|
await testInfo.attach('browser-errors', { body: JSON.stringify(errors, null, 2), contentType: 'application/json' });
|
|
}
|
|
});
|
|
});
|
|
}
|