aps-agent/apps/web/e2e/flex-export-download.spec.ts

119 lines
6.5 KiB
TypeScript
Raw Normal View History

import { expect, test, type Page } from '@playwright/test';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
// 柔性工作台导出下载的浏览器验收:先用真实接口把「导入 → 采用 → 排产」铺好,
// 再验证「订单方案 / 设备方案」经由鉴权下载通道拿到真实 xlsx。这条路径以前用
// href 直开 /api 链接,浏览器带不上鉴权头只会拿到 401 页面,因此这里断言真实
// 下载事件、200 响应与字节一致,不依赖模型网关。
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 apiUrl = new URL(API_TARGET);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(apiUrl.hostname)
|| apiUrl.protocol !== 'http:' || !apiUrl.port
|| ['8000', '8003', '5173'].includes(apiUrl.port)) {
throw new Error('Flex export download acceptance requires an isolated loopback backend.');
}
const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const WORKBOOK_NAME = '湖南锐扬APS精简演示数据.xlsx';
async function seedScheduledVersion(page: Page): Promise<string> {
const login = await page.request.post('/api/auth/login', {
data: { tenantName: 'flex-export-download', username: 'planner', password: 'test' },
});
expect(login.ok(), await login.text()).toBeTruthy();
const created = await page.request.post('/api/projects', { data: { name: `导出下载-${Date.now()}` } });
expect(created.ok(), await created.text()).toBeTruthy();
const workspace = await (await page.request.get('/api/workspace')).json();
const sessionId = String(workspace.activeSessionId);
const previewResponse = await page.request.post('/api/import/preview', {
multipart: { file: { name: WORKBOOK_NAME, mimeType: XLSX_MIME, buffer: readFileSync(SOURCE!) } },
});
expect(previewResponse.ok(), await previewResponse.text()).toBeTruthy();
const preview = await previewResponse.json();
expect(preview.totalOk).toBeGreaterThan(0);
const commit = await page.request.post('/api/import/commit', {
data: { filename: WORKBOOK_NAME, sessionId, batches: preview.batches },
});
expect(commit.ok(), await commit.text()).toBeTruthy();
const confirmId = (await commit.json()).block?.props?.confirmId;
expect(confirmId, 'import commit must stage a P2 confirmation card').toBeTruthy();
const confirm = await page.request.post('/api/actions/confirm', {
data: { confirmId, approve: true, sessionId },
});
expect(confirm.ok(), await confirm.text()).toBeTruthy();
const schedule = await page.request.post('/api/flex/schedule', { data: { sessionId } });
expect(schedule.ok(), await schedule.text()).toBeTruthy();
const scheduleBody = await schedule.json();
const versionId = scheduleBody.result?.versionId;
expect(versionId, JSON.stringify(scheduleBody).slice(0, 400)).toBeTruthy();
return String(versionId);
}
test('flex workbench downloads order and equipment exports with authentication', async ({ page }) => {
test.skip(!SOURCE && !REQUIRED, 'Set ROUND87_SOURCE to the explicitly authorized original workbook.');
test.setTimeout(300_000);
page.setDefaultTimeout(25_000);
const health = await page.request.get(`${API_TARGET}/__e2e__/health`);
expect(health.ok(), 'start tests/e2e/round87_masterdata_server.py first').toBeTruthy();
expect(await health.json()).toMatchObject({ isolated: true, sourceUnchanged: true, productionCode: true });
const versionId = await seedScheduledVersion(page);
const errors: string[] = [];
page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
page.on('console', message => {
if (message.type() === 'error') errors.push(`console: ${message.text()}`);
});
// 导出必须走应用内的鉴权 fetch(会带上 X-APS-Visitor-ID 等请求头),
// 而不是浏览器直接导航到 /api 链接——后者在桌面/头鉴权部署里只会拿到 401。
const exportRequestHeaders = new Map<string, Record<string, string>>();
page.on('request', request => {
const path = new URL(request.url()).pathname;
if (path === '/api/reports/schedule-order' || path === '/api/reports/schedule-equipment') {
exportRequestHeaders.set(path, request.headers());
}
});
await page.goto('/');
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
await page.locator('.right-rail:not(.guest-right-rail) button[data-tip="柔性工作台"]').click();
const panel = page.locator('.flex-bench');
await expect(panel).toBeVisible();
await expect(panel).toContainText('导出文件显式绑定当前版本');
const hash = (value: Buffer) => createHash('sha256').update(value).digest('hex');
const cases = [
['订单方案', 'schedule-order'],
['设备方案', 'schedule-equipment'],
] as const;
for (const [label, reportType] of cases) {
// antd 图标会进入可访问名称,这里按文本匹配避免版本差异。
const button = panel.getByRole('button', { name: new RegExp(label) }).last();
await button.scrollIntoViewIfNeeded();
const responsePromise = page.waitForResponse(response =>
new URL(response.url()).pathname === `/api/reports/${reportType}`
&& response.request().method() === 'GET');
const downloadPromise = page.waitForEvent('download');
await button.click();
const [response, download] = await Promise.all([responsePromise, downloadPromise]);
expect(response.status(), `${label} must not fall back to an unauthenticated 401 page`).toBe(200);
const requestHeaders = exportRequestHeaders.get(`/api/reports/${reportType}`) || {};
expect(requestHeaders['x-aps-visitor-id'], `${label} must download through the authenticated fetch path`)
.toBeTruthy();
expect(download.suggestedFilename()).toMatch(/\.xlsx$/i);
const downloadPath = await download.path();
expect(downloadPath).toBeTruthy();
const browserBytes = readFileSync(downloadPath!);
expect(browserBytes.subarray(0, 2).toString()).toBe('PK');
// 走前端同源代理比对字节,避免不同端口上的直连请求丢失登录态。
const apiResponse = await page.request.get(
`/api/reports/${reportType}?versionId=${encodeURIComponent(versionId)}`);
expect(apiResponse.ok(), await apiResponse.text()).toBeTruthy();
expect(hash(browserBytes)).toBe(hash(await apiResponse.body()));
await expect(panel.locator('.ant-alert')).toContainText('已下载');
}
expect(errors, 'browser errors collected during flex export download').toEqual([]);
});