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'; /** * 上传文件到指定输入框 * * @description * 从 fixtures 目录加载测试文件并上传到指定的文件输入框。 * 自动处理文件路径解析、存在性检查和错误处理。 * * 支持的选择器策略: * - `data-testid` - 推荐,最稳定 * - `aria-label` - 无障碍属性 * - CSS 选择器 - 直接使用 * * @param page - Playwright Page 对象 * @param selector - 文件输入框的选择器 * @param fileName - 要上传的文件名(相对于 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'); * * // 自定义 fixtures 目录 * await uploadFileToField(page, 'photo-upload', 'sample-id-card.jpg', { * fixturesDir: 'custom/fixtures/path' * }); * * // 支持子目录 * await uploadFileToField(page, 'document-upload', 'docs/resume.pdf', { * fixturesDir: 'tests/fixtures' * }); * ``` */ export async function uploadFileToField( page: Page, selector: string, fileName: string, options?: FileUploadOptions ): Promise { console.debug(`[uploadFileToField] 开始上传: selector="${selector}", fileName="${fileName}"`); // 1. 合并默认配置 const config = { timeout: options?.timeout ?? DEFAULT_TIMEOUTS.static, fixturesDir: options?.fixturesDir ?? 'web/tests/fixtures', waitForUpload: options?.waitForUpload ?? true }; // 2. 解析文件路径 const filePath = resolveFixturePath(fileName, config.fixturesDir); console.debug(`[uploadFileToField] 解析文件路径: ${filePath}`); // 3. 检查文件是否存在 if (!fs.existsSync(filePath)) { throwError({ operation: 'uploadFileToField', target: `文件 "${fileName}"`, expected: `文件存在于 ${filePath}`, suggestion: '检查文件名是否正确,或确认文件已添加到 fixtures 目录' }); } // 4. 查找文件输入框并上传 try { const fileInput = page.locator(selector); console.debug(`[uploadFileToField] 找到文件输入框,准备上传`); // 使用 setInputFiles API await fileInput.setInputFiles(filePath, { timeout: config.timeout }); console.debug(`[uploadFileToField] setInputFiles 完成`); // 5. 等待上传完成(如果需要) if (config.waitForUpload) { // 等待一小段时间确保文件上传处理完成 await page.waitForTimeout(200); console.debug(`[uploadFileToField] 上传等待完成`); } console.debug(`[uploadFileToField] 上传完成`); } catch (error) { // 选择器无效或其他错误 - 提供详细的上下文信息 const errorMessage = error instanceof Error ? error.message : '未知错误'; throwError({ operation: 'uploadFileToField', target: `选择器 "${selector}"`, expected: '文件输入框存在于页面且可访问', actual: `错误: ${errorMessage}`, suggestion: [ '检查选择器是否正确(推荐使用 data-testid)', '确认文件输入框已渲染到页面', '确认元素可见且未被隐藏(display: none)', '检查是否需要等待页面加载完成' ].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; }