import { expect, test, type Page } from '@playwright/test'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; // 排产导出模板下载的浏览器验收:只在显式指定授权工作簿时运行,指向隔离后端。 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('Schedule template download acceptance requires an isolated loopback backend.'); } async function checkIsolation(page: Page): Promise { const direct = await page.request.get(`${API_TARGET}/__e2e__/health`); expect(direct.ok(), 'start tests/e2e/round87_masterdata_server.py first').toBeTruthy(); expect(await direct.json()).toMatchObject({ isolated: true, sourceUnchanged: true, productionCode: true }); const proxied = await page.request.get('/api/__e2e__/health'); expect(proxied.ok(), 'frontend proxy must target the isolated Round 87 host').toBeTruthy(); } async function createProject(page: Page): Promise { await page.locator('.workspace-sidebar:not(.guest-sidebar) button[title="新建项目"]').click(); const dialog = page.getByRole('dialog', { name: '新建项目', exact: true }); await expect(dialog).toBeVisible(); await dialog.locator('input').first().fill(`排产模板下载-${Date.now()}`); const created = page.waitForResponse(response => new URL(response.url()).pathname === '/api/projects' && response.request().method() === 'POST'); await dialog.getByRole('button', { name: '创建', exact: true }).click(); expect((await created).ok()).toBeTruthy(); await expect(dialog).toBeHidden(); await expect(page.locator('.composer textarea')).toBeVisible(); } test('flex workbench downloads the contract-driven schedule template', async ({ page }) => { test.skip(!SOURCE && !REQUIRED, 'Set ROUND87_SOURCE to the explicitly authorized original workbook.'); test.setTimeout(300_000); page.setDefaultTimeout(25_000); await checkIsolation(page); const login = await page.request.post('/api/auth/login', { data: { tenantName: 'schedule-template-download', username: 'planner', password: 'test' }, }); expect(login.ok(), await login.text()).toBeTruthy(); 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()}`); }); page.on('response', response => { if (response.status() >= 500 && new URL(response.url()).pathname.startsWith('/api/')) { errors.push(`HTTP ${response.status()}: ${response.url()}`); } }); await page.goto('/'); await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 }); await createProject(page); await page.locator('.right-rail:not(.guest-right-rail) button[data-tip="柔性工作台"]').click(); const panel = page.locator('.flex-bench'); await expect(panel).toBeVisible(); // antd 图标会进入可访问名称(download 排产模板),这里按文本匹配避免版本差异。 const button = panel.getByRole('button', { name: /排产模板/ }); await button.scrollIntoViewIfNeeded(); const [download, response] = await Promise.all([ page.waitForEvent('download'), page.waitForResponse(actual => new URL(actual.url()).pathname === '/api/reports/schedule-template'), button.click(), ]); expect(response.status()).toBe(200); expect(response.headers()['x-aps-template-id']).toBe('schedule-plan.v1'); expect(response.headers()['x-aps-contract-digest']).toMatch(/^[0-9a-f]{64}$/); expect(download.suggestedFilename()).toMatch(/\.xlsx$/i); const path = await download.path(); expect(path).toBeTruthy(); const browserBytes = readFileSync(path!); expect(browserBytes.subarray(0, 2).toString()).toBe('PK'); expect(browserBytes.length).toBeGreaterThan(4096); // 模板不含业务行:UI 下载必须与鉴权接口逐字节一致,且同一合同重复下载稳定。 const apiTemplate = await page.request.get('/api/reports/schedule-template'); expect(apiTemplate.ok(), await apiTemplate.text()).toBeTruthy(); const hash = (value: Buffer) => createHash('sha256').update(value).digest('hex'); expect(hash(browserBytes)).toBe(hash(await apiTemplate.body())); await expect(panel.locator('.ant-alert')).toContainText('已下载'); expect(errors, 'browser errors collected during schedule template download').toEqual([]); });