Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

88 wiersze
3.8 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { expect, test as base, type Locator, type Page, type TestInfo } from '@playwright/test'
  4. export const runId = (process.env.APE2E_RUN_ID || 'manual').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'manual'
  5. export const runPrefix = `APE2E-${runId.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()}`
  6. const screenshotDirectory = path.resolve('artifacts', 'runs', runId, 'screenshots')
  7. const safeName = (value: string) => value.normalize('NFKC')
  8. .replace(/[^\p{L}\p{N}._-]+/gu, '-')
  9. .replace(/^-+|-+$/g, '')
  10. .slice(0, 120)
  11. const stableTestName = (testInfo: TestInfo) => {
  12. const fileStem = path.basename(testInfo.file, path.extname(testInfo.file))
  13. return safeName(`${testInfo.project.name}--${fileStem}--${testInfo.title}`).slice(0, 110) || 'unnamed-test'
  14. }
  15. export async function captureScreenshot(page: Page, testInfo: TestInfo, label: string, masks: Locator[] = []) {
  16. fs.mkdirSync(screenshotDirectory, { recursive: true })
  17. const artifactName = safeName(label).slice(0, 70) || 'success'
  18. const screenshotPath = path.join(screenshotDirectory, `${stableTestName(testInfo)}--${artifactName}.png`)
  19. await page.screenshot({
  20. path: screenshotPath,
  21. fullPage: true,
  22. animations: 'disabled',
  23. caret: 'hide',
  24. mask: masks,
  25. maskColor: '#1f2937',
  26. })
  27. await testInfo.attach(`success-${artifactName}`, { path: screenshotPath, contentType: 'image/png' })
  28. return screenshotPath
  29. }
  30. export async function attachJson(testInfo: TestInfo, name: string, value: unknown) {
  31. const serialized = JSON.stringify(value, (_key, item) => {
  32. if (typeof item === 'string' && /(?:Bearer\s+|eyJ)[A-Za-z0-9._~-]+/i.test(item)) return '<redacted>'
  33. return item
  34. }, 2)
  35. await testInfo.attach(name, { body: Buffer.from(serialized), contentType: 'application/json' })
  36. }
  37. export const test = base
  38. test.beforeEach(async ({}, testInfo) => {
  39. fs.mkdirSync(screenshotDirectory, { recursive: true })
  40. const prefix = `${stableTestName(testInfo)}--`
  41. for (const filename of fs.readdirSync(screenshotDirectory)) {
  42. if (filename.startsWith(prefix) && filename.endsWith('.png')) fs.rmSync(path.join(screenshotDirectory, filename), { force: true })
  43. }
  44. })
  45. test.afterEach(async ({ page }, testInfo) => {
  46. if (testInfo.status === testInfo.expectedStatus || page.isClosed()) return
  47. fs.mkdirSync(screenshotDirectory, { recursive: true })
  48. const filename = `${safeName(testInfo.titlePath.join('--')) || 'failed-test'}--retry-${testInfo.retry}--${Date.now()}.png`
  49. const screenshotPath = path.join(screenshotDirectory, filename)
  50. try {
  51. // 失败截图同样必须遵守敏感信息约束。部分密码框支持“显示密码”,
  52. // 因此除了 type=password,还要覆盖 autocomplete 标记和一次性密码结果区。
  53. const sensitiveControls = page.locator([
  54. 'input[type="password"]',
  55. 'input[autocomplete="current-password"]',
  56. 'input[autocomplete="new-password"]',
  57. '[data-testid="generated-password"]',
  58. '[data-testid="password-result-secret"]',
  59. // 远控终端创建/轮换后的密钥由 Element Plus 警告框仅展示一次。
  60. // 即使测试恰好在该警告框打开时失败,失败截图也不得带出完整密钥。
  61. '.el-message-box:has-text("接入密钥")',
  62. '[role="alertdialog"]:has-text("接入密钥")',
  63. ].join(', '))
  64. await page.screenshot({
  65. path: screenshotPath,
  66. fullPage: true,
  67. animations: 'disabled',
  68. caret: 'hide',
  69. mask: [sensitiveControls],
  70. maskColor: '#1f2937',
  71. })
  72. await testInfo.attach('failure-screenshot', { path: screenshotPath, contentType: 'image/png' })
  73. } catch (error) {
  74. await testInfo.attach('failure-screenshot-error', { body: Buffer.from(String(error)), contentType: 'text/plain' })
  75. }
  76. })
  77. export { expect }