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; async function browserApi(page: Page, apiPath: string): Promise { 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 { 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 { 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 { 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 { 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 { 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' }); } }); }); }