file-upload.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  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. * @internal
  11. */
  12. type FileNames = string | string[];
  13. /**
  14. * 上传文件到指定输入框(单文件)
  15. *
  16. * @description
  17. * 从 fixtures 目录加载单个测试文件并上传到指定的文件输入框。
  18. *
  19. * @param page - Playwright Page 对象
  20. * @param selector - 文件输入框的选择器
  21. * @param fileName - 要上传的文件名(相对于 fixtures 目录)
  22. * @param options - 可选配置
  23. *
  24. * @example
  25. * ```ts
  26. * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg');
  27. * ```
  28. */
  29. export async function uploadFileToField(
  30. page: Page,
  31. selector: string,
  32. fileName: string,
  33. options?: FileUploadOptions
  34. ): Promise<void>;
  35. /**
  36. * 上传多个文件到指定输入框(多文件)
  37. *
  38. * @description
  39. * 从 fixtures 目录加载多个测试文件并一次性上传到指定的文件输入框。
  40. * 适用于 `<input type="file" multiple>` 场景。
  41. *
  42. * @param page - Playwright Page 对象
  43. * @param selector - 文件输入框的选择器
  44. * @param fileNames - 要上传的文件名数组(相对于 fixtures 目录)
  45. * @param options - 可选配置
  46. *
  47. * @example
  48. * ```ts
  49. * await uploadFileToField(page, 'photo-upload', [
  50. * 'sample-id-card.jpg',
  51. * 'sample-disability-card.jpg'
  52. * ]);
  53. * ```
  54. */
  55. export async function uploadFileToField(
  56. page: Page,
  57. selector: string,
  58. fileNames: string[],
  59. options?: FileUploadOptions
  60. ): Promise<void>;
  61. /**
  62. * 上传文件到指定输入框(统一实现)
  63. *
  64. * @description
  65. * 从 fixtures 目录加载测试文件并上传到指定的文件输入框。
  66. * 支持单文件和多文件上传,自动处理文件路径解析、存在性检查和错误处理。
  67. *
  68. * 支持的选择器策略:
  69. * - `data-testid` - 推荐,最稳定
  70. * - `aria-label` - 无障碍属性
  71. * - CSS 选择器 - 直接使用
  72. *
  73. * @param page - Playwright Page 对象
  74. * @param selector - 文件输入框的选择器
  75. * @param fileNames - 要上传的文件名(或文件名数组,相对于 fixtures 目录)
  76. * @param options - 可选配置
  77. * @param options.fixturesDir - fixtures 目录路径,默认为 'web/tests/fixtures'
  78. * @param options.timeout - 超时时间(毫秒),默认 5000ms
  79. * @param options.waitForUpload - 是否等待上传完成,默认为 true
  80. * @throws {E2ETestError} 当文件不存在或选择器无效时
  81. *
  82. * @example
  83. * ```ts
  84. * // 单文件上传
  85. * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg');
  86. *
  87. * // 多文件上传
  88. * await uploadFileToField(page, 'photo-upload', [
  89. * 'sample-id-card.jpg',
  90. * 'sample-disability-card.jpg'
  91. * ]);
  92. *
  93. * // 自定义 fixtures 目录
  94. * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg', {
  95. * fixturesDir: 'custom/fixtures/path'
  96. * });
  97. * ```
  98. */
  99. export async function uploadFileToField(
  100. page: Page,
  101. selector: string,
  102. fileNames: FileNames,
  103. options?: FileUploadOptions
  104. ): Promise<void> {
  105. // 检查是否为多文件上传
  106. const isMultiple = Array.isArray(fileNames);
  107. const fileCount = isMultiple ? fileNames.length : 1;
  108. console.debug(`[uploadFileToField] 开始上传: selector="${selector}", ${isMultiple ? `fileNames=[${fileNames.join(', ')}]` : `fileName="${fileNames}"`}`);
  109. // 1. 合并默认配置
  110. const config = {
  111. timeout: options?.timeout ?? DEFAULT_TIMEOUTS.static,
  112. fixturesDir: options?.fixturesDir ?? 'web/tests/fixtures',
  113. waitForUpload: options?.waitForUpload ?? true
  114. };
  115. // 2. 验证文件列表不为空(多文件场景)
  116. if (isMultiple && fileNames.length === 0) {
  117. throwError({
  118. operation: 'uploadFileToField',
  119. target: `选择器 "${selector}"`,
  120. expected: '至少提供一个文件路径',
  121. suggestion: '文件列表不能为空,请至少提供一个文件名'
  122. });
  123. }
  124. // 3. 解析所有文件路径
  125. const filePaths: string[] = [];
  126. const missingFiles: string[] = [];
  127. if (isMultiple) {
  128. // 多文件:解析每个文件路径
  129. for (const fileName of fileNames) {
  130. const filePath = resolveFixturePath(fileName, config.fixturesDir);
  131. if (fs.existsSync(filePath)) {
  132. filePaths.push(filePath);
  133. } else {
  134. missingFiles.push(fileName);
  135. }
  136. }
  137. } else {
  138. // 单文件:解析文件路径(fileNames 此时是 string)
  139. const filePath = resolveFixturePath(fileNames as string, config.fixturesDir);
  140. if (fs.existsSync(filePath)) {
  141. filePaths.push(filePath);
  142. } else {
  143. missingFiles.push(fileNames as string);
  144. }
  145. }
  146. // 4. 检查是否有文件不存在
  147. if (missingFiles.length > 0) {
  148. const isPartialMissing = isMultiple && filePaths.length > 0;
  149. // 单文件上传:保持原有错误格式
  150. // 多文件上传:使用新的增强格式
  151. throwError({
  152. operation: 'uploadFileToField',
  153. target: isMultiple
  154. ? `选择器 "${selector}"`
  155. : `文件 "${missingFiles[0]}"`,
  156. expected: isMultiple
  157. ? `所有文件存在于 fixtures 目录`
  158. : `文件存在于 ${resolveFixturePath(missingFiles[0], config.fixturesDir)}`,
  159. actual: isMultiple
  160. ? `缺失文件: ${missingFiles.join(', ')}`
  161. : undefined,
  162. suggestion: isPartialMissing
  163. ? `以下文件不存在:\n ${missingFiles.map(f => ` - ${f}`).join('\n')}\n 可用文件:\n ${fileNames.filter(f => !missingFiles.includes(f)).map(f => ` - ${f}`).join('\n')}`
  164. : isMultiple
  165. ? `检查文件名是否正确,或确认文件已添加到 fixtures 目录\n 缺失文件: ${missingFiles.join(', ')}`
  166. : '检查文件名是否正确,或确认文件已添加到 fixtures 目录'
  167. });
  168. }
  169. console.debug(`[uploadFileToField] 解析文件路径: ${filePaths.join(', ')}`);
  170. // 5. 查找文件输入框并上传
  171. try {
  172. const fileInput = page.locator(selector);
  173. console.debug(`[uploadFileToField] 找到文件输入框,准备上传 ${fileCount} 个文件`);
  174. // 使用 setInputFiles API
  175. // 单文件:传入字符串;多文件:传入数组
  176. const inputFiles = isMultiple ? filePaths : filePaths[0];
  177. await fileInput.setInputFiles(inputFiles, { timeout: config.timeout });
  178. console.debug(`[uploadFileToField] setInputFiles 完成`);
  179. // 6. 等待上传完成(如果需要)
  180. if (config.waitForUpload) {
  181. // 等待一小段时间确保文件上传处理完成
  182. await page.waitForTimeout(200);
  183. console.debug(`[uploadFileToField] 上传等待完成`);
  184. }
  185. console.debug(`[uploadFileToField] 上传完成 (${fileCount} 个文件)`);
  186. } catch (error) {
  187. // 选择器无效或其他错误 - 提供详细的上下文信息
  188. const errorMessage = error instanceof Error ? error.message : '未知错误';
  189. throwError({
  190. operation: 'uploadFileToField',
  191. target: `选择器 "${selector}"`,
  192. expected: '文件输入框存在于页面且可访问',
  193. actual: `错误: ${errorMessage}`,
  194. suggestion: [
  195. '检查选择器是否正确(推荐使用 data-testid)',
  196. '确认文件输入框已渲染到页面',
  197. '确认元素可见且未被隐藏(display: none)',
  198. '检查是否需要等待页面加载完成',
  199. isMultiple ? '确认 input 元素有 multiple 属性支持多文件上传' : ''
  200. ].filter(Boolean).join('\n ')
  201. });
  202. }
  203. }
  204. /**
  205. * 解析 fixtures 文件路径
  206. *
  207. * @description
  208. * 将相对文件名解析为绝对路径。
  209. * 支持相对于 fixtures 目录的路径和子目录。
  210. *
  211. * @internal
  212. *
  213. * @param fileName - 文件名(可能包含子目录)
  214. * @param fixturesDir - fixtures 基础目录
  215. * @returns 解析后的绝对路径
  216. */
  217. function resolveFixturePath(fileName: string, fixturesDir: string): string {
  218. const normalizedFileName = path.normalize(fileName);
  219. // 拒绝绝对路径和向上遍历路径
  220. if (normalizedFileName.startsWith("..") || path.isAbsolute(normalizedFileName)) {
  221. throwError({
  222. operation: "uploadFileToField",
  223. target: fileName,
  224. suggestion: "文件名必须是相对于 fixtures 目录的路径,不能使用 '..' 或绝对路径"
  225. });
  226. }
  227. // 解析完整路径
  228. const resolvedPath = path.resolve(path.join(fixturesDir, normalizedFileName));
  229. const resolvedFixturesDir = path.resolve(fixturesDir);
  230. // 验证解析后的路径在 fixtures 目录内(防止路径遍历攻击)
  231. if (!resolvedPath.startsWith(resolvedFixturesDir)) {
  232. throwError({
  233. operation: "uploadFileToField",
  234. target: fileName,
  235. suggestion: "文件名路径试图访问 fixtures 目录之外的文件"
  236. });
  237. }
  238. return resolvedPath;
  239. }