import * as fs from 'node:fs'; import * as path from 'node:path'; import type { Page } from '@playwright/test'; import type { FileUploadOptions } from './types'; import { throwError } from './errors'; import { DEFAULT_TIMEOUTS } from './constants'; /** * 文件名参数类型(单文件或多文件) * * @internal */ type FileNames = string | string[]; /** * 上传文件到指定输入框(单文件) * * @description * 从 fixtures 目录加载单个测试文件并上传到指定的文件输入框。 * * @param page - Playwright Page 对象 * @param selector - 文件输入框的选择器 * @param fileName - 要上传的文件名(相对于 fixtures 目录) * @param options - 可选配置 * * @example * ```ts * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg'); * ``` */ export async function uploadFileToField( page: Page, selector: string, fileName: string, options?: FileUploadOptions ): Promise; /** * 上传多个文件到指定输入框(多文件) * * @description * 从 fixtures 目录加载多个测试文件并一次性上传到指定的文件输入框。 * 适用于 `` 场景。 * * @param page - Playwright Page 对象 * @param selector - 文件输入框的选择器 * @param fileNames - 要上传的文件名数组(相对于 fixtures 目录) * @param options - 可选配置 * * @example * ```ts * await uploadFileToField(page, 'photo-upload', [ * 'sample-id-card.jpg', * 'sample-disability-card.jpg' * ]); * ``` */ export async function uploadFileToField( page: Page, selector: string, fileNames: string[], options?: FileUploadOptions ): Promise; /** * 上传文件到指定输入框(统一实现) * * @description * 从 fixtures 目录加载测试文件并上传到指定的文件输入框。 * 支持单文件和多文件上传,自动处理文件路径解析、存在性检查和错误处理。 * * 支持的选择器策略: * - `data-testid` - 推荐,最稳定 * - `aria-label` - 无障碍属性 * - CSS 选择器 - 直接使用 * * @param page - Playwright Page 对象 * @param selector - 文件输入框的选择器 * @param fileNames - 要上传的文件名(或文件名数组,相对于 fixtures 目录) * @param options - 可选配置 * @param options.fixturesDir - fixtures 目录路径,默认为 'web/tests/fixtures' * @param options.timeout - 超时时间(毫秒),默认 5000ms * @param options.waitForUpload - 是否等待上传完成,默认为 true * @throws {E2ETestError} 当文件不存在或选择器无效时 * * @example * ```ts * // 单文件上传 * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg'); * * // 多文件上传 * await uploadFileToField(page, 'photo-upload', [ * 'sample-id-card.jpg', * 'sample-disability-card.jpg' * ]); * * // 自定义 fixtures 目录 * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg', { * fixturesDir: 'custom/fixtures/path' * }); * ``` */ export async function uploadFileToField( page: Page, selector: string, fileNames: FileNames, options?: FileUploadOptions ): Promise { // 检查是否为多文件上传 const isMultiple = Array.isArray(fileNames); const fileCount = isMultiple ? fileNames.length : 1; console.debug(`[uploadFileToField] 开始上传: selector="${selector}", ${isMultiple ? `fileNames=[${fileNames.join(', ')}]` : `fileName="${fileNames}"`}`); // 1. 合并默认配置 const config = { timeout: options?.timeout ?? DEFAULT_TIMEOUTS.static, fixturesDir: options?.fixturesDir ?? 'web/tests/fixtures', waitForUpload: options?.waitForUpload ?? true }; // 2. 验证文件列表不为空(多文件场景) if (isMultiple && fileNames.length === 0) { throwError({ operation: 'uploadFileToField', target: `选择器 "${selector}"`, expected: '至少提供一个文件路径', suggestion: '文件列表不能为空,请至少提供一个文件名' }); } // 3. 解析所有文件路径 const filePaths: string[] = []; const missingFiles: string[] = []; if (isMultiple) { // 多文件:解析每个文件路径 for (const fileName of fileNames) { const filePath = resolveFixturePath(fileName, config.fixturesDir); if (fs.existsSync(filePath)) { filePaths.push(filePath); } else { missingFiles.push(fileName); } } } else { // 单文件:解析文件路径(fileNames 此时是 string) const filePath = resolveFixturePath(fileNames as string, config.fixturesDir); if (fs.existsSync(filePath)) { filePaths.push(filePath); } else { missingFiles.push(fileNames as string); } } // 4. 检查是否有文件不存在 if (missingFiles.length > 0) { const isPartialMissing = isMultiple && filePaths.length > 0; // 单文件上传:保持原有错误格式 // 多文件上传:使用新的增强格式 throwError({ operation: 'uploadFileToField', target: isMultiple ? `选择器 "${selector}"` : `文件 "${missingFiles[0]}"`, expected: isMultiple ? `所有文件存在于 fixtures 目录` : `文件存在于 ${resolveFixturePath(missingFiles[0], config.fixturesDir)}`, actual: isMultiple ? `缺失文件: ${missingFiles.join(', ')}` : undefined, suggestion: isPartialMissing ? `以下文件不存在:\n ${missingFiles.map(f => ` - ${f}`).join('\n')}\n 可用文件:\n ${fileNames.filter(f => !missingFiles.includes(f)).map(f => ` - ${f}`).join('\n')}` : isMultiple ? `检查文件名是否正确,或确认文件已添加到 fixtures 目录\n 缺失文件: ${missingFiles.join(', ')}` : '检查文件名是否正确,或确认文件已添加到 fixtures 目录' }); } console.debug(`[uploadFileToField] 解析文件路径: ${filePaths.join(', ')}`); // 5. 查找文件输入框并上传 try { const fileInput = page.locator(selector); console.debug(`[uploadFileToField] 找到文件输入框,准备上传 ${fileCount} 个文件`); // 使用 setInputFiles API // 单文件:传入字符串;多文件:传入数组 const inputFiles = isMultiple ? filePaths : filePaths[0]; await fileInput.setInputFiles(inputFiles, { timeout: config.timeout }); console.debug(`[uploadFileToField] setInputFiles 完成`); // 6. 等待上传完成(如果需要) if (config.waitForUpload) { // 等待一小段时间确保文件上传处理完成 await page.waitForTimeout(200); console.debug(`[uploadFileToField] 上传等待完成`); } console.debug(`[uploadFileToField] 上传完成 (${fileCount} 个文件)`); } catch (error) { // 选择器无效或其他错误 - 提供详细的上下文信息 const errorMessage = error instanceof Error ? error.message : '未知错误'; throwError({ operation: 'uploadFileToField', target: `选择器 "${selector}"`, expected: '文件输入框存在于页面且可访问', actual: `错误: ${errorMessage}`, suggestion: [ '检查选择器是否正确(推荐使用 data-testid)', '确认文件输入框已渲染到页面', '确认元素可见且未被隐藏(display: none)', '检查是否需要等待页面加载完成', isMultiple ? '确认 input 元素有 multiple 属性支持多文件上传' : '' ].filter(Boolean).join('\n ') }); } } /** * 解析 fixtures 文件路径 * * @description * 将相对文件名解析为绝对路径。 * 支持相对于 fixtures 目录的路径和子目录。 * * @internal * * @param fileName - 文件名(可能包含子目录) * @param fixturesDir - fixtures 基础目录 * @returns 解析后的绝对路径 */ function resolveFixturePath(fileName: string, fixturesDir: string): string { const normalizedFileName = path.normalize(fileName); // 拒绝绝对路径和向上遍历路径 if (normalizedFileName.startsWith("..") || path.isAbsolute(normalizedFileName)) { throwError({ operation: "uploadFileToField", target: fileName, suggestion: "文件名必须是相对于 fixtures 目录的路径,不能使用 '..' 或绝对路径" }); } // 解析完整路径 const resolvedPath = path.resolve(path.join(fixturesDir, normalizedFileName)); const resolvedFixturesDir = path.resolve(fixturesDir); // 验证解析后的路径在 fixtures 目录内(防止路径遍历攻击) if (!resolvedPath.startsWith(resolvedFixturesDir)) { throwError({ operation: "uploadFileToField", target: fileName, suggestion: "文件名路径试图访问 fixtures 目录之外的文件" }); } return resolvedPath; }