aps-agent/apps/web/e2e/planning-schedule-entry.spe...

289 lines
17 KiB
TypeScript
Raw Normal View History

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<string, any>;
async function get(page: Page, url: string): Promise<Json> {
const response = await page.request.get(url);
expect(response.ok(), await response.text()).toBeTruthy();
return response.json() as Promise<Json>;
}
async function send(page: Page, text: string): Promise<void> {
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<void> {
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<Json> {
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<void>(resolve => { arrive = resolve; });
const released = new Promise<void>(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' });
}
});
});