import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; const API_TARGET = process.env.E2E_API_TARGET || 'http://127.0.0.1:8115'; const apiUrl = new URL(API_TARGET); if (['127.0.0.1', 'localhost'].includes(apiUrl.hostname) && apiUrl.port === '8003') { throw new Error('Round 65 closed-loop E2E refuses live port 8003; start tests/e2e/round65_closed_loop_server.py.'); } type Json = Record; test.describe.configure({ mode: 'serial' }); test.beforeEach(async ({ request }) => { const health = await request.get(`${API_TARGET}/__e2e__/health`); expect(health.ok(), 'isolated backend health check failed').toBeTruthy(); const body = await health.json() as Json; expect(body.liveWorldSha256Now).toBe(body.liveWorldSha256); }); async function switchFixture(request: APIRequestContext, fixture: 'mom' | 'ready'): Promise { const response = await request.post(`${API_TARGET}/__e2e__/fixture/${fixture}`); expect(response.ok(), `fixture switch failed: ${fixture}`).toBeTruthy(); const body = await response.json() as Json; expect(body.fixture).toBe(fixture); expect(body.liveWorldSha256Now).toBe(body.liveWorldSha256); } async function loginThroughUi(page: Page, username = 'planner'): Promise { await page.goto('/'); const trigger = page.locator('.guest-login-trigger'); await expect(trigger).toBeEnabled({ timeout: 30_000 }); await trigger.click(); const dialog = page.locator('.auth-login-dialog'); await expect(dialog).toBeVisible(); await dialog.locator('input[placeholder="\u8bf7\u8f93\u5165\u4f01\u4e1a\u540d\u79f0"]').fill('round65-e2e'); await dialog.locator('input[placeholder="\u8bf7\u8f93\u5165\u7528\u6237\u540d"]').fill(username); await dialog.locator('input[placeholder="\u8bf7\u8f93\u5165\u5bc6\u7801"]').fill('round65-test'); await dialog.locator('.login-submit').click(); await expect(dialog).toBeHidden({ timeout: 30_000 }); await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 }); } async function openOrders(page: Page): Promise { const existing = page.getByTestId('closed-loop-aps-stages'); if (await existing.isVisible().catch(() => false)) return; const trigger = page.locator('.right-rail button[data-tip="\u8ba2\u5355\u7ba1\u7406"]'); await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); await expect(page.getByTestId('closed-loop-aps-stages')).toBeVisible({ timeout: 30_000 }); } async function browserApi( page: Page, path: string, options: { method?: string; body?: Json } = {}, ): Promise { const result = await page.evaluate(async ({ apiPath, method, body }) => { const response = await fetch(apiPath, { method: method || 'GET', headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, credentials: 'include', }); const text = await response.text(); let payload: unknown = {}; try { payload = text ? JSON.parse(text) : {}; } catch { payload = { text }; } return { status: response.status, payload }; }, { apiPath: path, method: options.method, body: options.body }); expect(result.status, `${options.method || 'GET'} ${path}: ${JSON.stringify(result.payload)}`).toBe(200); return result.payload as T; } async function refreshOrders(page: Page): Promise { // A real document refresh proves that state is restored from the isolated // backend rather than surviving only in React component memory. await page.reload(); await openOrders(page); } async function openMakeTab(page: Page): Promise { const tab = page.getByRole('tab', { name: new RegExp('\u5236\u9020\u9700\u6c42') }).first(); await expect(tab).toBeVisible(); await tab.click(); } function tagClass(page: Page, stage: string) { return page.getByTestId(`closed-loop-stage-${stage}-status`); } test.describe('Round 65 real browser closed-loop APS E2E (isolated backend)', () => { test('A) MOM deep copy yields 106 MAKE / 124 BUY blockers, persists after refresh, and keeps P2/P3 closed', async ({ page, request }) => { await switchFixture(request, 'mom'); await loginThroughUi(page); await openOrders(page); const decomposition = await browserApi(page, '/api/mrp/decompose', { method: 'POST', body: { sessionId: 'round65-mom', orderNo: 'MOM--00280' }, }); const closedLoop = decomposition.result.closedLoop; expect(closedLoop.summary).toMatchObject({ orderCount: 1, makeCount: 106, buyCount: 124, subcontractCount: 0, }); expect(closedLoop.summary.blockerCounts).toMatchObject({ MISSING_ROUTING: 39, TEMPLATE_ROUTING_UNCONFIRMED: 67, MISSING_RESOURCE_CAPABILITY: 67, }); expect(closedLoop.projection.readyDemandCount).toBe(0); const schedule = await browserApi(page, '/api/flex/schedule', { method: 'POST', body: { sessionId: 'round65-mom', sortMode: 'ASC' }, }); expect(schedule.result).toMatchObject({ solveStatus: 'BLOCKED', demandCount: 106, admittedDemandCount: 0, vlCount: 0, woCount: 0, }); const snapshot = await browserApi(page, '/api/orders'); expect(snapshot.closedLoop.summary).toMatchObject({ makeCount: 106, buyCount: 124 }); expect(snapshot.closedLoopStatus.latestVersion).toMatchObject({ solveStatus: 'BLOCKED', vlCount: 0, woCount: 0, }); expect(snapshot.closedLoopStatus.gate.publishReady).toBe(false); expect(snapshot.closedLoopStatus.gate.dispatchReady).toBe(false); await refreshOrders(page); await expect(tagClass(page, 'commercial')).toContainText('1'); await expect(tagClass(page, 'netting')).toContainText('230'); await expect(tagClass(page, 'admission')).toContainText('0'); await expect(page.getByTestId('closed-loop-stage-draft')).toContainText(/VL\s*0\s*\/\s*WO\s*0/); expect(await tagClass(page, 'publish').getAttribute('class')).not.toMatch(/ant-tag-(green|gold)/); expect(await tagClass(page, 'mes').getAttribute('class')).not.toMatch(/ant-tag-(green|gold)/); await openMakeTab(page); await expect(page.getByTestId('closed-loop-manufacturing-demands')).toBeVisible(); await expect(page.getByTestId('closed-loop-blockers')).toBeVisible(); const stageCard = page.getByTestId('closed-loop-aps-stages'); await expect(stageCard).toContainText('\u7f3a\u5de5\u827a\u8def\u7ebf 39'); await expect(stageCard).toContainText('\u6a21\u677f\u5de5\u827a\u5f85\u786e\u8ba4 67'); await expect(stageCard).toContainText('\u7f3a\u8d44\u6e90\u80fd\u529b 67'); const problemId = snapshot.closedLoop.problemId; const versionId = snapshot.closedLoopStatus.latestVersion.id; await page.reload(); await openOrders(page); const restored = await browserApi(page, '/api/orders'); expect(restored.closedLoop.problemId).toBe(problemId); expect(restored.closedLoopStatus.latestVersion.id).toBe(versionId); expect(restored.closedLoopStatus.gate.publishReady).toBe(false); expect(restored.closedLoopStatus.gate.dispatchReady).toBe(false); await expect(page.getByTestId('closed-loop-stage-draft')).toContainText(/VL\s*0\s*\/\s*WO\s*0/); const isolatedState = await request.get(`${API_TARGET}/__e2e__/state`); const isolatedBody = await isolatedState.json() as Json; expect(isolatedBody.liveWorldSha256Now).toBe(isolatedBody.liveWorldSha256); expect(isolatedBody.virtualLineCount).toBe(0); expect(isolatedBody.workOrderCount).toBe(0); }); test('B) ready fixture yields FEASIBLE 1 VL/1 WO, publishes through P2, and dispatches through dual-approval P3', async ({ page, request }) => { await switchFixture(request, 'ready'); await loginThroughUi(page); await openOrders(page); const decomposition = await browserApi(page, '/api/mrp/decompose', { method: 'POST', body: { sessionId: 'round65-ready', orderNo: 'SO-READY' }, }); expect(decomposition.result.closedLoop.summary).toMatchObject({ makeCount: 1, buyCount: 1, blockedRequirementCount: 0, }); const schedule = await browserApi(page, '/api/flex/schedule', { method: 'POST', body: { sessionId: 'round65-ready', sortMode: 'ASC' }, }); expect(schedule.result).toMatchObject({ solveStatus: 'FEASIBLE', vlCount: 1, woCount: 1, conflictCount: 0, }); expect(schedule.result.validation.valid).toBe(true); const versionId = schedule.result.versionId as number; const versionNo = schedule.result.versionNo as string; await refreshOrders(page); await expect(page.getByTestId('closed-loop-stage-draft')).toContainText(versionNo); await expect(page.getByTestId('closed-loop-stage-draft')).toContainText(/VL\s*1\s*\/\s*WO\s*1/); expect(await tagClass(page, 'draft').getAttribute('class')).toMatch(/ant-tag-green/); expect(await tagClass(page, 'publish').getAttribute('class')).toMatch(/ant-tag-gold/); const stagedPublish = await browserApi(page, '/api/schedule/publish/stage', { method: 'POST', body: { sessionId: 'round65-ready', track: 'flex', versionId }, }); expect(stagedPublish.staged).toBe(true); expect(stagedPublish.confirmId).toBeTruthy(); const published = await browserApi(page, '/api/actions/confirm', { method: 'POST', body: { sessionId: 'round65-ready', confirmId: stagedPublish.confirmId, approve: true, }, }); expect(published.secondConfirmRequired).toBe(false); expect(published.refresh).toBe(true); let status = await browserApi(page, '/api/orders'); expect(status.closedLoopStatus.latestVersion.status).toBe('PUBLISHED'); expect(status.closedLoopStatus.gate.publishReady).toBe(false); expect(status.closedLoopStatus.gate.dispatchReady).toBe(true); await refreshOrders(page); expect(await tagClass(page, 'publish').getAttribute('class')).toMatch(/ant-tag-green/); expect(await tagClass(page, 'mes').getAttribute('class')).toMatch(/ant-tag-gold/); const stagedMes = await browserApi(page, '/api/mes/dispatch/stage', { method: 'POST', body: { sessionId: 'round65-ready', track: 'flex' }, }); expect(stagedMes.staged).toBe(true); const mesConfirmId = stagedMes.block?.props?.confirmId; expect(mesConfirmId).toBeTruthy(); const firstApproval = await browserApi(page, '/api/actions/confirm', { method: 'POST', body: { sessionId: 'round65-ready', confirmId: mesConfirmId, approve: true }, }); expect(firstApproval.secondConfirmRequired).toBe(true); expect(firstApproval.refresh).toBe(false); const secondLogin = await browserApi(page, '/api/auth/login', { method: 'POST', body: { tenantName: 'round65-e2e', username: 'collaborator', password: 'round65-test', }, }); expect(secondLogin.user.username).toBe('collaborator'); const secondApproval = await browserApi(page, '/api/actions/confirm', { method: 'POST', body: { sessionId: 'round65-ready-collaborator', confirmId: mesConfirmId, approve: true }, }); expect(secondApproval.secondConfirmRequired).toBe(false); expect(secondApproval.refresh).toBe(true); status = await browserApi(page, '/api/orders'); expect(status.closedLoopStatus.latestVersion.status).toBe('DISPATCHED'); expect(status.closedLoopStatus.gate.dispatchReady).toBe(true); const execution = await browserApi(page, '/api/mes/execution?track=flex'); expect(execution.versionNo).toBe(versionNo); expect(execution.total).toBe(1); expect(execution.rows).toHaveLength(1); expect(execution.rows[0].mesExternalId).toBeTruthy(); await refreshOrders(page); expect(await tagClass(page, 'publish').getAttribute('class')).toMatch(/ant-tag-green/); expect(await tagClass(page, 'mes').getAttribute('class')).toMatch(/ant-tag-green/); const isolatedState = await request.get(`${API_TARGET}/__e2e__/state`); const isolatedBody = await isolatedState.json() as Json; expect(isolatedBody.mesLinkCount).toBe(1); expect(isolatedBody.liveWorldSha256Now).toBe(isolatedBody.liveWorldSha256); }); });