import { expect, test, type Page } from '@playwright/test'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; 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 expectationPath = process.env.ROUND87_EXPECTATIONS || fileURLToPath(new URL('../../../tests/fixtures/planning-workbook-acceptance.json', import.meta.url)); const EXPECTED = JSON.parse(readFileSync(expectationPath, 'utf-8')); 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('Intake 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('master data panel downloads the exact template bytes the API serves', 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: 'intake-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('.master-panel'); await expect(panel).toBeVisible(); const button = panel.getByRole('button', { name: '下载采集模板', exact: true }); await button.scrollIntoViewIfNeeded(); const [download, response] = await Promise.all([ page.waitForEvent('download'), page.waitForResponse(actual => new URL(actual.url()).pathname === '/api/import/template'), button.click(), ]); expect(response.status()).toBe(200); expect(response.headers()['x-aps-profile-id']).toBe(EXPECTED.profile); expect(response.headers()['x-aps-template-id']).toBe(`intake-${EXPECTED.profile}.v1`); 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/import/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('.import-bar')).toContainText('已下载'); expect(errors, 'browser errors collected during template download').toEqual([]); });