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 target = new URL(API_TARGET); if (target.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname) || !target.port || ['8000', '8003', '5173'].includes(target.port)) { throw new Error('Scheduling entry 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; async function get(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 { const backToChat = page.getByRole('button', { name: '返回对话', exact: true }); if (await backToChat.isVisible()) await backToChat.click(); await page.locator('.composer textarea').fill(text); await expect(page.locator('.composer button.send')).toBeEnabled(); await page.locator('.composer button.send').click(); } async function inspectDetails(card: Locator): Promise { for (const [key, label] of [['orders', '订单记录'], ['materials', '产品和物料'], ['routing', '加工步骤'], ['equipment', '设备记录']]) { await card.getByRole('button', { name: `查看${label}明细`, exact: true }).click({ force: true }); const details = card.locator('.planning-object-details'); await expect(details).toContainText(`共 ${EXPECTED.entityCounts[key]} 条`); await expect(details.locator('tbody tr').first()).toBeVisible(); await details.getByRole('searchbox').fill('__NO_MATCH__'); await expect(details).toContainText('没有匹配的记录'); await details.getByRole('searchbox').fill(''); await details.getByRole('button', { name: '收起明细', exact: true }).click({ force: true }); await expect(details).toBeHidden(); } } async function scheduleFromComposer(page: Page): Promise { const request = page.waitForRequest(req => new URL(req.url()).pathname === '/api/chat' && req.method() === 'POST' && req.postDataJSON().text === '立即排产'); await send(page, '立即排产'); const actual = await request; expect(actual.postDataJSON().text).toBe('立即排产'); const response = await actual.response(); expect(response?.ok()).toBeTruthy(); await response!.finished(); const events = (await response!.text()).split(/\r?\n\r?\n/) .filter(frame => frame.startsWith('data: ')).map(frame => JSON.parse(frame.slice(6)) as Json); const schedule = events.find(event => event.type === 'block' && event.block?.type === 'flex-schedule'); expect(schedule, 'actual scheduling response must include a structured result block').toBeTruthy(); expect(events.some(event => event.type === 'done')).toBe(true); return { ...await get(page, '/api/flex/world'), schedulingResult: schedule!.block.props }; } for (const viewport of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'mobile', width: 390, height: 844 }]) { test.describe(`analysis scheduling entry (${viewport.name})`, () => { test.use({ viewport }); test.skip(!SOURCE && !REQUIRED, 'Opt in with the authorized ROUND87_SOURCE workbook.'); test('analysis card adopts the workbook, runs a trial schedule, and downloads the Excel', async ({ page }, info) => { test.setTimeout(300_000); page.setDefaultTimeout(25_000); expect(SOURCE, 'ROUND87_REQUIRED requires a workbook').toBeTruthy(); const sourceHash = createHash('sha256').update(readFileSync(SOURCE!)).digest('hex'); expect(sourceHash).toBe(EXPECTED.sourceSha256); const direct = await get(page, `${API_TARGET}/__e2e__/health`); expect(direct).toMatchObject({ isolated: true, sourceSha256: sourceHash, sourceUnchanged: true }); const proxy = await get(page, '/api/__e2e__/health'); expect(proxy.runtimeRoot).toBe(direct.runtimeRoot); const login = await page.request.post('/api/auth/login', { data: { username: 'planner', password: 'test' } }); expect(login.ok()).toBeTruthy(); const errors: string[] = []; const scheduleRequests: string[] = []; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); page.on('request', request => { if (new URL(request.url()).pathname === '/api/chat' && request.method() === 'POST' && request.postDataJSON().text === '立即排产') scheduleRequests.push(request.postData() || ''); }); try { // Project creation is arrangement for this focused scheduling test. // Navigate only after the backend has bound its actual session; a // separate project-navigation race must not swallow the uploaded file. const projectName = `排产入口验收-${viewport.name}-${Date.now()}`; const creation = await page.request.post('/api/projects', { data: { name: projectName } }); expect(creation.ok(), await creation.text()).toBeTruthy(); const created = await creation.json() as Json; const workspace = await get(page, '/api/workspace'); const projectId = workspace.activeProjectId; const sessionId = workspace.activeSessionId; expect(projectId).toBe(created.project.id); expect(workspace.sessions.some((session: Json) => session.id === sessionId && session.projectId === projectId)).toBe(true); const messagesPath = `/api/sessions/${sessionId}/messages`; const initialMessages = page.waitForResponse(response => new URL(response.url()).pathname === messagesPath && response.request().method() === 'GET'); const hydrated = page.waitForResponse(response => new URL(response.url()).pathname === messagesPath && response.request().method() === 'PUT'); await page.goto('/'); const loaded = await initialMessages; expect(loaded.ok()).toBeTruthy(); await loaded.finished(); // This save is emitted only after the bound ChatPanel has loaded its // server history and enabled persistence. No sleep or forced click. expect((await hydrated).ok()).toBeTruthy(); await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 }); expect((await get(page, '/api/workspace')).activeSessionId).toBe(sessionId); await page.locator('.composer-file-input').setInputFiles(SOURCE!); await expect(page.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 send(page, '分析一下数据文件'); 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({ sessionId, projectId }); const cards = page.locator('.planning-data-card'); await expect(cards.last()).toContainText('排产资料核对', { timeout: 90_000 }); const start = cards.last().getByRole('button', { name: '开始排产', exact: true }); await expect(start).toBeVisible(); await expect(start).toBeDisabled(); // Adoption is pending; no duplicate confirm request. await inspectDetails(cards.last()); const beforeApprove = await get(page, '/api/flex/world'); expect(beforeApprove.orders).toHaveLength(0); 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(); expect((await (await confirmation).json() as Json).refresh).toBe(true); await page.reload(); await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 }); await expect(cards.last()).toContainText('历史记录'); const adopted = await get(page, '/api/flex/world'); expect(adopted.orders.map((o: Json) => o.orderNo).sort()).toEqual([...EXPECTED.formalOrderNos].sort()); const originalInventory = adopted.materials.map((m: Json) => ({ code: m.code, stock: m.stock, inTransit: m.inTransit, expectedArrivalDate: m.expectedArrivalDate })); const trial = await scheduleFromComposer(page); expect(scheduleRequests).toHaveLength(1); expect(trial.schedulingResult.solveStatus).toBe('PARTIAL_WITH_ASSUMPTIONS'); expect(trial.schedulingResult.stats.woCount).toBeGreaterThan(0); expect(trial.workOrders.length).toBeGreaterThan(0); expect(trial.orders).toHaveLength(EXPECTED.entityCounts.orders); expect(trial.materials.map((m: Json) => ({ code: m.code, stock: m.stock, inTransit: m.inTransit, expectedArrivalDate: m.expectedArrivalDate }))).toEqual(originalInventory); await expect(page.locator('.flex-block').last()).toContainText('试排结果'); await expect(page.locator('.flex-block').last().locator('.flex-line-row:not(.blocked)').first()).toBeVisible(); const backToChat = page.getByRole('button', { name: '返回对话', exact: true }); if (await backToChat.isVisible()) await backToChat.click(); const downloadButton = page.locator('.flex-block').last() .getByRole('button', { name: '下载 Excel 工作计划表', exact: true }); await downloadButton.scrollIntoViewIfNeeded(); const reportResponse = page.waitForResponse(response => new URL(response.url()).pathname.startsWith('/api/reports/') && response.request().method() === 'GET'); const reportDownload = page.waitForEvent('download'); await downloadButton.click(); const [downloadResponse, download] = await Promise.all([reportResponse, reportDownload]); expect(downloadResponse.status()).toBe(200); expect(download.suggestedFilename()).toMatch(/\.xlsx$/i); const downloadPath = await download.path(); expect(downloadPath).toBeTruthy(); expect(readFileSync(downloadPath!).subarray(0, 2).toString()).toBe('PK'); const gantt = await get(page, '/api/flex/gantt'); expect(gantt.workOrders.length).toBeGreaterThan(0); const widths = await page.evaluate(() => ({ document: document.documentElement.scrollWidth, viewport: innerWidth })); expect(widths.document).toBeLessThanOrEqual(widths.viewport + 1); const screenshot = path.join(mkdtempSync(path.join(os.tmpdir(), 'aps-scheduling-entry-')), `${viewport.name}.png`); await page.screenshot({ path: screenshot, fullPage: true }); await info.attach('scheduling-entry', { path: screenshot, contentType: 'image/png' }); expect(errors).toEqual([]); expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(sourceHash); } finally { await info.attach('browser-errors', { body: JSON.stringify(errors, null, 2), contentType: 'application/json' }); } }); }); } test.describe('new project navigation preserves immediate attachments', () => { test.use({ viewport: { width: 1440, height: 900 } }); test.skip(!SOURCE && !REQUIRED, 'Opt in with the authorized workbook.'); test('real delayed create, workspace and history requests prevent input until the new session is ready', async ({ page }) => { test.setTimeout(180_000); page.setDefaultTimeout(25_000); expect(SOURCE).toBeTruthy(); const health = await get(page, `${API_TARGET}/__e2e__/health`); expect(health).toMatchObject({ isolated: true, sourceUnchanged: true, sourceSha256: EXPECTED.sourceSha256 }); expect((await get(page, '/api/__e2e__/health')).runtimeRoot).toBe(health.runtimeRoot); const login = await page.request.post('/api/auth/login', { data: { username: 'planner', password: 'test' } }); expect(login.ok()).toBeTruthy(); await page.goto('/'); const readyChat = page.locator('section[data-session-ready="true"]'); await expect(readyChat).toBeVisible({ timeout: 60_000 }); const previousSession = await readyChat.getAttribute('data-session-id'); function gate() { let arrive!: () => void; let release!: () => void; const reached = new Promise(resolve => { arrive = resolve; }); const released = new Promise(resolve => { release = resolve; }); return { arrive, release, reached, released }; } const creationGate = gate(); const workspaceGate = gate(); const historyGate = gate(); let waitForWorkspace = false; let waitForHistory = false; let nextSession = ''; // Gates delay real requests only; the production backend still creates // projects, loads history, uploads the workbook and analyzes every row. await page.route('**/api/projects', async route => { if (route.request().method() === 'POST') { creationGate.arrive(); await creationGate.released; } await route.continue(); }); await page.route('**/api/workspace', async route => { if (route.request().method() === 'GET' && waitForWorkspace) { workspaceGate.arrive(); await workspaceGate.released; } await route.continue(); }); await page.route('**/api/sessions/*/messages', async route => { const matched = new URL(route.request().url()).pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/); if (route.request().method() === 'GET' && waitForHistory && matched && matched[1] !== previousSession) { nextSession = matched[1]; historyGate.arrive(); await historyGate.released; } await route.continue(); }); try { await page.locator('.workspace-sidebar button[title="新建项目"]').click(); const dialog = page.getByRole('dialog', { name: '新建项目', exact: true }); await dialog.locator('input').first().fill(`导航就绪验收-${Date.now()}`); await dialog.locator('.dlg-actions .btn-primary').click(); await creationGate.reached; await expect(dialog).toBeVisible(); await expect(dialog.locator('.dlg-actions .btn-primary')).toBeDisabled(); await expect(dialog.locator('input').first()).toBeDisabled(); waitForWorkspace = true; creationGate.release(); await workspaceGate.reached; await expect(dialog).toBeVisible(); await expect(dialog.locator('.dlg-actions .btn-primary')).toBeDisabled(); waitForHistory = true; workspaceGate.release(); await historyGate.reached; const newChat = page.locator(`section[data-session-id="${nextSession}"]`); await expect(newChat).toHaveAttribute('data-session-ready', 'false'); await expect(newChat.locator('.composer textarea')).toBeDisabled(); await expect(newChat.locator('.composer button.send')).toBeDisabled(); await expect(dialog).toBeHidden(); historyGate.release(); await expect(newChat).toHaveAttribute('data-session-ready', 'true'); await expect(newChat.locator('.composer textarea')).toBeEnabled(); await newChat.locator('.composer-file-input').setInputFiles(SOURCE!); await expect(newChat.locator('.material-strip')).toContainText(path.basename(SOURCE!)); const analysis = page.waitForRequest(request => new URL(request.url()).pathname === '/api/chat' && request.method() === 'POST' && request.postDataJSON().text === '分析一下数据文件'); await send(page, '分析一下数据文件'); expect((await analysis).postDataJSON().sessionId).toBe(nextSession); const card = newChat.locator('.planning-data-card').last(); await expect(card).toContainText('排产资料核对', { timeout: 90_000 }); // 真实 Pi 路径用服务端口径「产品和物料」,纯前端兜底文案是「产品和材料」;两者都代表同一张卡。 await expect(card.getByRole('button', { name: /查看产品和(物料|材料)明细/ }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.materials)); await expect(card.getByRole('button', { name: '开始排产', exact: true })).toBeVisible(); expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(EXPECTED.sourceSha256); } finally { creationGate.release(); workspaceGate.release(); historyGate.release(); await page.unrouteAll({ behavior: 'wait' }); } }); });