| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- 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<void>;
- /**
- * 上传多个文件到指定输入框(多文件)
- *
- * @description
- * 从 fixtures 目录加载多个测试文件并一次性上传到指定的文件输入框。
- * 适用于 `<input type="file" multiple>` 场景。
- *
- * @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<void>;
- /**
- * 上传文件到指定输入框(统一实现)
- *
- * @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<void> {
- // 检查是否为多文件上传
- 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;
- }
|