2026-08-20 11:39:21 +08:00
|
|
|
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<string, any>;
|
|
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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 });
|
2026-09-08 00:07:26 +08:00
|
|
|
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
|
2026-08-20 11:39:21 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openOrders(page: Page): Promise<void> {
|
2026-09-08 00:36:13 +08:00
|
|
|
// A document reload briefly renders AuthGate, whose guest rail exposes the
|
|
|
|
|
// same data-tip. Wait for the authenticated shell and exclude that rail so
|
|
|
|
|
// the click cannot be consumed by the login dialog during bootstrap.
|
|
|
|
|
await expect(page.locator('.account-trigger:not(.guest-login-trigger)')).toBeVisible({ timeout: 60_000 });
|
2026-08-20 11:39:21 +08:00
|
|
|
const existing = page.getByTestId('closed-loop-aps-stages');
|
|
|
|
|
if (await existing.isVisible().catch(() => false)) return;
|
2026-09-08 00:36:13 +08:00
|
|
|
const trigger = page.locator('.right-rail:not(.guest-right-rail) button[data-tip="\u8ba2\u5355\u7ba1\u7406"]');
|
2026-08-20 11:39:21 +08:00
|
|
|
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<T extends Json>(
|
|
|
|
|
page: Page,
|
|
|
|
|
path: string,
|
|
|
|
|
options: { method?: string; body?: Json } = {},
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
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<void> {
|
|
|
|
|
// 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<void> {
|
2026-09-08 00:07:26 +08:00
|
|
|
const tab = page.getByRole('tab', { name: new RegExp('\u5236\u9020\u9700\u6c42') }).first();
|
2026-08-20 11:39:21 +08:00
|
|
|
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<Json>(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<Json>(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<Json>(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/);
|
2026-09-08 00:36:13 +08:00
|
|
|
await expect(tagClass(page, 'publish')).toHaveText('\u672a\u5f00\u653e');
|
|
|
|
|
await expect(tagClass(page, 'mes')).toHaveText('\u672a\u5f00\u653e');
|
2026-08-20 11:39:21 +08:00
|
|
|
|
|
|
|
|
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<Json>(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<Json>(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<Json>(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/);
|
2026-09-08 00:36:13 +08:00
|
|
|
await expect(tagClass(page, 'draft')).toHaveText('FEASIBLE');
|
|
|
|
|
await expect(tagClass(page, 'publish')).toHaveText('\u5f85\u786e\u8ba4');
|
2026-08-20 11:39:21 +08:00
|
|
|
|
|
|
|
|
const stagedPublish = await browserApi<Json>(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<Json>(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<Json>(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);
|
2026-09-08 00:36:13 +08:00
|
|
|
await expect(tagClass(page, 'publish')).toHaveText('\u5df2\u53d1\u5e03');
|
|
|
|
|
await expect(tagClass(page, 'mes')).toHaveText('\u5f85\u53cc\u4eba\u786e\u8ba4');
|
2026-08-20 11:39:21 +08:00
|
|
|
|
|
|
|
|
const stagedMes = await browserApi<Json>(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<Json>(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<Json>(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<Json>(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<Json>(page, '/api/orders');
|
|
|
|
|
expect(status.closedLoopStatus.latestVersion.status).toBe('DISPATCHED');
|
|
|
|
|
expect(status.closedLoopStatus.gate.dispatchReady).toBe(true);
|
|
|
|
|
const execution = await browserApi<Json>(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);
|
2026-09-08 00:36:13 +08:00
|
|
|
await expect(tagClass(page, 'publish')).toHaveText('\u5df2\u53d1\u5e03');
|
|
|
|
|
await expect(tagClass(page, 'mes')).toHaveText('\u5df2\u4e0b\u53d1');
|
2026-08-20 11:39:21 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|