aps-agent/apps/web/e2e/intake-template-download.sp...

94 lines
4.8 KiB
TypeScript
Raw 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';
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<void> {
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<void> {
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([]);
});