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