288 lines
17 KiB
TypeScript
288 lines
17 KiB
TypeScript
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 });
|
|
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' });
|
|
}
|
|
});
|
|
});
|