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

119 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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([]);
});