file-upload.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import * as fs from 'node:fs';
  2. import * as path from 'node:path';
  3. import type { Page } from '@playwright/test';
  4. import type { FileUploadOptions } from './types';
  5. import { throwError } from './errors';
  6. import { DEFAULT_TIMEOUTS } from './constants';
  7. /**
  8. * 上传文件到指定输入框
  9. *
  10. * @description
  11. * 从 fixtures 目录加载测试文件并上传到指定的文件输入框。
  12. * 自动处理文件路径解析、存在性检查和错误处理。
  13. *
  14. * 支持的选择器策略:
  15. * - `data-testid` - 推荐,最稳定
  16. * - `aria-label` - 无障碍属性
  17. * - CSS 选择器 - 直接使用
  18. *
  19. * @param page - Playwright Page 对象
  20. * @param selector - 文件输入框的选择器
  21. * @param fileName - 要上传的文件名(相对于 fixtures 目录)
  22. * @param options - 可选配置
  23. * @param options.fixturesDir - fixtures 目录路径,默认为 'web/tests/fixtures'
  24. * @param options.timeout - 超时时间(毫秒),默认 5000ms
  25. * @param options.waitForUpload - 是否等待上传完成,默认为 true
  26. * @throws {E2ETestError} 当文件不存在或选择器无效时
  27. *
  28. * @example
  29. * ```ts
  30. * // 默认配置上传
  31. * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg');
  32. *
  33. * // 自定义 fixtures 目录
  34. * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg', {
  35. * fixturesDir: 'custom/fixtures/path'
  36. * });
  37. *
  38. * // 支持子目录
  39. * await uploadFileToField(page, 'document-upload', 'docs/resume.pdf', {
  40. * fixturesDir: 'tests/fixtures'
  41. * });
  42. * ```
  43. */
  44. export async function uploadFileToField(
  45. page: Page,
  46. selector: string,
  47. fileName: string,
  48. options?: FileUploadOptions
  49. ): Promise<void> {
  50. console.debug(`[uploadFileToField] 开始上传: selector="${selector}", fileName="${fileName}"`);
  51. // 1. 合并默认配置
  52. const config = {
  53. timeout: options?.timeout ?? DEFAULT_TIMEOUTS.static,
  54. fixturesDir: options?.fixturesDir ?? 'web/tests/fixtures',
  55. waitForUpload: options?.waitForUpload ?? true
  56. };
  57. // 2. 解析文件路径
  58. const filePath = resolveFixturePath(fileName, config.fixturesDir);
  59. console.debug(`[uploadFileToField] 解析文件路径: ${filePath}`);
  60. // 3. 检查文件是否存在
  61. if (!fs.existsSync(filePath)) {
  62. throwError({
  63. operation: 'uploadFileToField',
  64. target: `文件 "${fileName}"`,
  65. expected: `文件存在于 ${filePath}`,
  66. suggestion: '检查文件名是否正确,或确认文件已添加到 fixtures 目录'
  67. });
  68. }
  69. // 4. 查找文件输入框并上传
  70. try {
  71. const fileInput = page.locator(selector);
  72. console.debug(`[uploadFileToField] 找到文件输入框,准备上传`);
  73. // 使用 setInputFiles API
  74. await fileInput.setInputFiles(filePath, { timeout: config.timeout });
  75. console.debug(`[uploadFileToField] setInputFiles 完成`);
  76. // 5. 等待上传完成(如果需要)
  77. if (config.waitForUpload) {
  78. // 等待一小段时间确保文件上传处理完成
  79. await page.waitForTimeout(200);
  80. console.debug(`[uploadFileToField] 上传等待完成`);
  81. }
  82. console.debug(`[uploadFileToField] 上传完成`);
  83. } catch (error) {
  84. // 选择器无效或其他错误 - 提供详细的上下文信息
  85. const errorMessage = error instanceof Error ? error.message : '未知错误';
  86. throwError({
  87. operation: 'uploadFileToField',
  88. target: `选择器 "${selector}"`,
  89. expected: '文件输入框存在于页面且可访问',
  90. actual: `错误: ${errorMessage}`,
  91. suggestion: [
  92. '检查选择器是否正确(推荐使用 data-testid)',
  93. '确认文件输入框已渲染到页面',
  94. '确认元素可见且未被隐藏(display: none)',
  95. '检查是否需要等待页面加载完成'
  96. ].join('\n ')
  97. });
  98. }
  99. }
  100. /**
  101. * 解析 fixtures 文件路径
  102. *
  103. * @description
  104. * 将相对文件名解析为绝对路径。
  105. * 支持相对于 fixtures 目录的路径和子目录。
  106. *
  107. * @internal
  108. *
  109. * @param fileName - 文件名(可能包含子目录)
  110. * @param fixturesDir - fixtures 基础目录
  111. * @returns 解析后的绝对路径
  112. */
  113. function resolveFixturePath(fileName: string, fixturesDir: string): string {
  114. const normalizedFileName = path.normalize(fileName);
  115. // 拒绝绝对路径和向上遍历路径
  116. if (normalizedFileName.startsWith("..") || path.isAbsolute(normalizedFileName)) {
  117. throwError({
  118. operation: "uploadFileToField",
  119. target: fileName,
  120. suggestion: "文件名必须是相对于 fixtures 目录的路径,不能使用 '..' 或绝对路径"
  121. });
  122. }
  123. // 解析完整路径
  124. const resolvedPath = path.resolve(path.join(fixturesDir, normalizedFileName));
  125. const resolvedFixturesDir = path.resolve(fixturesDir);
  126. // 验证解析后的路径在 fixtures 目录内(防止路径遍历攻击)
  127. if (!resolvedPath.startsWith(resolvedFixturesDir)) {
  128. throwError({
  129. operation: "uploadFileToField",
  130. target: fileName,
  131. suggestion: "文件名路径试图访问 fixtures 目录之外的文件"
  132. });
  133. }
  134. return resolvedPath;
  135. }