import fs from 'node:fs' import path from 'node:path' import { expect, test as base, type Locator, type Page, type TestInfo } from '@playwright/test' export const runId = (process.env.APE2E_RUN_ID || 'manual').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'manual' export const runPrefix = `APE2E-${runId.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()}` const screenshotDirectory = path.resolve('artifacts', 'runs', runId, 'screenshots') const safeName = (value: string) => value.normalize('NFKC') .replace(/[^\p{L}\p{N}._-]+/gu, '-') .replace(/^-+|-+$/g, '') .slice(0, 120) const stableTestName = (testInfo: TestInfo) => { const fileStem = path.basename(testInfo.file, path.extname(testInfo.file)) return safeName(`${testInfo.project.name}--${fileStem}--${testInfo.title}`).slice(0, 110) || 'unnamed-test' } export async function captureScreenshot(page: Page, testInfo: TestInfo, label: string, masks: Locator[] = []) { fs.mkdirSync(screenshotDirectory, { recursive: true }) const artifactName = safeName(label).slice(0, 70) || 'success' const screenshotPath = path.join(screenshotDirectory, `${stableTestName(testInfo)}--${artifactName}.png`) await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled', caret: 'hide', mask: masks, maskColor: '#1f2937', }) await testInfo.attach(`success-${artifactName}`, { path: screenshotPath, contentType: 'image/png' }) return screenshotPath } export async function attachJson(testInfo: TestInfo, name: string, value: unknown) { const serialized = JSON.stringify(value, (_key, item) => { if (typeof item === 'string' && /(?:Bearer\s+|eyJ)[A-Za-z0-9._~-]+/i.test(item)) return '' return item }, 2) await testInfo.attach(name, { body: Buffer.from(serialized), contentType: 'application/json' }) } export const test = base test.beforeEach(async ({}, testInfo) => { fs.mkdirSync(screenshotDirectory, { recursive: true }) const prefix = `${stableTestName(testInfo)}--` for (const filename of fs.readdirSync(screenshotDirectory)) { if (filename.startsWith(prefix) && filename.endsWith('.png')) fs.rmSync(path.join(screenshotDirectory, filename), { force: true }) } }) test.afterEach(async ({ page }, testInfo) => { if (testInfo.status === testInfo.expectedStatus || page.isClosed()) return fs.mkdirSync(screenshotDirectory, { recursive: true }) const filename = `${safeName(testInfo.titlePath.join('--')) || 'failed-test'}--retry-${testInfo.retry}--${Date.now()}.png` const screenshotPath = path.join(screenshotDirectory, filename) try { // 失败截图同样必须遵守敏感信息约束。部分密码框支持“显示密码”, // 因此除了 type=password,还要覆盖 autocomplete 标记和一次性密码结果区。 const sensitiveControls = page.locator([ 'input[type="password"]', 'input[autocomplete="current-password"]', 'input[autocomplete="new-password"]', '[data-testid="generated-password"]', '[data-testid="password-result-secret"]', // 远控终端创建/轮换后的密钥由 Element Plus 警告框仅展示一次。 // 即使测试恰好在该警告框打开时失败,失败截图也不得带出完整密钥。 '.el-message-box:has-text("接入密钥")', '[role="alertdialog"]:has-text("接入密钥")', ].join(', ')) await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled', caret: 'hide', mask: [sensitiveControls], maskColor: '#1f2937', }) await testInfo.attach('failure-screenshot', { path: screenshotPath, contentType: 'image/png' }) } catch (error) { await testInfo.attach('failure-screenshot-error', { body: Buffer.from(String(error)), contentType: 'text/plain' }) } }) export { expect }