2
0

file-upload.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
  1. /**
  2. * @vitest-environment node
  3. *
  4. * 文件上传工具函数单元测试
  5. *
  6. * 测试策略:
  7. * - 使用 vi.fn() 模拟 Playwright Page 对象和 Locator
  8. * - 测试 resolveFixturePath 函数的路径解析逻辑(核心测试重点)
  9. * - 验证错误处理和 E2ETestError 上下文完整性
  10. * - 验证安全防护机制(路径遍历攻击防护)
  11. * - 注意:单元测试无法替代真实 E2E 集成测试(见 Story 3.3)
  12. *
  13. * Epic 2 经验教训:
  14. * - 单元测试覆盖率目标 80%,但无法发现真实 DOM 问题
  15. * - 真实 E2E 测试是必需的,不是可选项
  16. * - 本测试重点:路径解析逻辑、错误处理、安全防护
  17. */
  18. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  19. import type { Page } from '@playwright/test';
  20. import * as fs from 'node:fs';
  21. // 从主导出点导入,验证 index.ts 导出配置正确
  22. import {
  23. uploadFileToField,
  24. E2ETestError,
  25. DEFAULT_TIMEOUTS,
  26. type FileUploadOptions
  27. } from '@d8d/e2e-test-utils';
  28. // Mock fs 模块
  29. vi.mock('node:fs', async () => {
  30. const actual = await vi.importActual('node:fs');
  31. return {
  32. ...actual,
  33. existsSync: vi.fn(),
  34. };
  35. });
  36. describe('uploadFileToField - 文件上传工具', () => {
  37. let mockPage: Page;
  38. let mockLocator: any;
  39. let mockExistsSync: ReturnType<typeof vi.mocked<typeof fs.existsSync>>;
  40. let consoleDebugSpy: ReturnType<typeof vi.spyOn>;
  41. beforeEach(() => {
  42. // 重置所有 mocks
  43. vi.clearAllMocks();
  44. // Mock console.debug 减少测试输出噪音
  45. consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
  46. // 创建 mock locator
  47. mockLocator = {
  48. setInputFiles: vi.fn().mockResolvedValue(undefined),
  49. };
  50. // 创建 mock page
  51. mockPage = {
  52. locator: vi.fn().mockReturnValue(mockLocator),
  53. waitForTimeout: vi.fn().mockResolvedValue(undefined),
  54. } as unknown as Page;
  55. // 获取 mock 的 existsSync
  56. mockExistsSync = vi.mocked(fs.existsSync);
  57. // 默认行为:文件存在
  58. mockExistsSync.mockReturnValue(true);
  59. });
  60. afterEach(() => {
  61. // 恢复 console.debug 原始实现
  62. consoleDebugSpy.mockRestore();
  63. });
  64. describe('Task 2: 成功上传场景测试', () => {
  65. describe('Subtask 2.1: 默认 fixtures 目录上传', () => {
  66. it('应该成功上传文件(使用默认 fixtures 目录)', async () => {
  67. // Arrange
  68. const fileName = 'test-sample.jpg';
  69. const selector = 'photo-upload';
  70. // Act
  71. await uploadFileToField(mockPage, selector, fileName);
  72. // Assert
  73. expect(mockPage.locator).toHaveBeenCalledWith(selector);
  74. expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
  75. expect.stringContaining(fileName),
  76. { timeout: DEFAULT_TIMEOUTS.static }
  77. );
  78. // 默认 waitForUpload: true,应该调用 waitForTimeout
  79. expect(mockPage.waitForTimeout).toHaveBeenCalledWith(200);
  80. });
  81. it('应该使用默认 fixtures 目录 "web/tests/fixtures"', async () => {
  82. // Arrange
  83. const fileName = 'sample.jpg';
  84. const selector = 'input-file';
  85. // Act
  86. await uploadFileToField(mockPage, selector, fileName);
  87. // Assert
  88. const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string;
  89. expect(filePathArg).toContain('web/tests/fixtures');
  90. expect(filePathArg).toContain(fileName);
  91. });
  92. });
  93. describe('Subtask 2.2: 自定义 fixtures 目录上传', () => {
  94. it('应该使用自定义 fixtures 目录上传文件', async () => {
  95. // Arrange
  96. const customFixturesDir = 'custom/fixtures/path';
  97. const fileName = 'test-sample.jpg';
  98. const selector = 'file-upload';
  99. // Act
  100. await uploadFileToField(mockPage, selector, fileName, {
  101. fixturesDir: customFixturesDir
  102. });
  103. // Assert
  104. const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string;
  105. expect(filePathArg).toContain(customFixturesDir);
  106. expect(filePathArg).toContain(fileName);
  107. });
  108. it('应该使用自定义 fixtures 目录解析绝对路径', async () => {
  109. // Arrange
  110. const customFixturesDir = 'tests/fixtures';
  111. const fileName = 'documents/test-sample.pdf';
  112. const selector = 'document-upload';
  113. // Act
  114. await uploadFileToField(mockPage, selector, fileName, {
  115. fixturesDir: customFixturesDir
  116. });
  117. // Assert
  118. const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string;
  119. expect(filePathArg).toContain(customFixturesDir);
  120. expect(filePathArg).toContain(fileName);
  121. });
  122. });
  123. describe('Subtask 2.3: 子目录文件上传', () => {
  124. it('应该支持子目录文件上传(如 images/sample.jpg)', async () => {
  125. // Arrange
  126. const fileName = 'images/sample-id-card.jpg';
  127. const selector = 'photo-upload';
  128. // Act
  129. await uploadFileToField(mockPage, selector, fileName);
  130. // Assert
  131. const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string;
  132. expect(filePathArg).toContain('images');
  133. expect(filePathArg).toContain('sample-id-card.jpg');
  134. });
  135. it('应该支持多级子目录文件上传', async () => {
  136. // Arrange
  137. const fileName = 'documents/2024/01/test-sample.pdf';
  138. const selector = 'document-upload';
  139. // Act
  140. await uploadFileToField(mockPage, selector, fileName);
  141. // Assert
  142. const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string;
  143. expect(filePathArg).toContain('documents/2024/01');
  144. expect(filePathArg).toContain('test-sample.pdf');
  145. });
  146. });
  147. describe('Subtask 2.4: 验证 setInputFiles API 调用', () => {
  148. it('应该使用正确的参数调用 setInputFiles', async () => {
  149. // Arrange
  150. const fileName = 'test.jpg';
  151. const selector = 'file-input';
  152. // Act
  153. await uploadFileToField(mockPage, selector, fileName);
  154. // Assert
  155. expect(mockLocator.setInputFiles).toHaveBeenCalledTimes(1);
  156. const callArgs = mockLocator.setInputFiles.mock.calls[0];
  157. expect(callArgs[0]).toEqual(expect.any(String)); // 文件路径
  158. expect(callArgs[1]).toEqual({ timeout: DEFAULT_TIMEOUTS.static });
  159. });
  160. it('应该先调用 locator 再调用 setInputFiles', async () => {
  161. // Arrange
  162. const fileName = 'test.jpg';
  163. const selector = 'my-input';
  164. // Act
  165. await uploadFileToField(mockPage, selector, fileName);
  166. // Assert - 验证调用顺序
  167. expect(mockPage.locator).toHaveBeenCalledWith(selector);
  168. expect(mockLocator.setInputFiles).toHaveBeenCalled();
  169. });
  170. });
  171. });
  172. describe('Task 3: 错误场景测试', () => {
  173. describe('Subtask 3.1: 文件不存在错误', () => {
  174. it('应该在文件不存在时抛出 E2ETestError', async () => {
  175. // Arrange
  176. const nonExistentFile = 'non-existent-file.jpg';
  177. mockExistsSync.mockReturnValue(false); // 文件不存在
  178. // Act & Assert
  179. await expect(
  180. uploadFileToField(mockPage, 'photo-upload', nonExistentFile)
  181. ).rejects.toThrow(E2ETestError);
  182. });
  183. it('文件不存在错误应该包含正确的上下文信息', async () => {
  184. // Arrange
  185. const nonExistentFile = 'missing-file.jpg';
  186. mockExistsSync.mockReturnValue(false);
  187. // Act & Assert
  188. try {
  189. await uploadFileToField(mockPage, 'photo-upload', nonExistentFile);
  190. expect.fail('应该抛出错误');
  191. } catch (error) {
  192. expect(error).toBeInstanceOf(E2ETestError);
  193. const e2eError = error as E2ETestError;
  194. expect(e2eError.context.operation).toBe('uploadFileToField');
  195. expect(e2eError.context.target).toContain(nonExistentFile);
  196. expect(e2eError.message).toContain('💡');
  197. }
  198. });
  199. });
  200. describe('Subtask 3.2: 选择器无效错误', () => {
  201. it('应该在选择器无效时抛出 E2ETestError', async () => {
  202. // Arrange
  203. mockLocator.setInputFiles.mockRejectedValue(
  204. new Error('Element not found')
  205. );
  206. const invalidSelector = 'invalid-file-input';
  207. // Act & Assert
  208. await expect(
  209. uploadFileToField(mockPage, invalidSelector, 'test.jpg')
  210. ).rejects.toThrow(E2ETestError);
  211. });
  212. it('选择器错误应该包含选择器上下文', async () => {
  213. // Arrange
  214. const invalidSelector = 'missing-input';
  215. mockLocator.setInputFiles.mockRejectedValue(
  216. new Error('Timeout waiting for element')
  217. );
  218. // Act & Assert
  219. try {
  220. await uploadFileToField(mockPage, invalidSelector, 'test.jpg');
  221. expect.fail('应该抛出错误');
  222. } catch (error) {
  223. expect(error).toBeInstanceOf(E2ETestError);
  224. const e2eError = error as E2ETestError;
  225. expect(e2eError.context.operation).toBe('uploadFileToField');
  226. expect(e2eError.context.target).toContain(invalidSelector);
  227. }
  228. });
  229. });
  230. describe('Subtask 3.3: 验证错误消息包含正确的上下文信息', () => {
  231. it('文件不存在错误应该包含建议', async () => {
  232. // Arrange
  233. mockExistsSync.mockReturnValue(false);
  234. // Act & Assert
  235. try {
  236. await uploadFileToField(mockPage, 'input', 'missing.jpg');
  237. expect.fail('应该抛出错误');
  238. } catch (error) {
  239. expect(error).toBeInstanceOf(E2ETestError);
  240. const e2eError = error as E2ETestError;
  241. expect(e2eError.context.suggestion).toBeDefined();
  242. expect(e2eError.context.suggestion).toContain('fixtures');
  243. }
  244. });
  245. it('选择器错误应该包含详细建议', async () => {
  246. // Arrange
  247. mockLocator.setInputFiles.mockRejectedValue(
  248. new Error('Element not found')
  249. );
  250. // Act & Assert
  251. try {
  252. await uploadFileToField(mockPage, 'bad-selector', 'test.jpg');
  253. expect.fail('应该抛出错误');
  254. } catch (error) {
  255. expect(error).toBeInstanceOf(E2ETestError);
  256. const e2eError = error as E2ETestError;
  257. expect(e2eError.context.suggestion).toBeDefined();
  258. expect(e2eError.context.suggestion).toContain('data-testid');
  259. }
  260. });
  261. });
  262. });
  263. describe('Task 4: 边界条件和安全测试', () => {
  264. describe('Subtask 4.1: 路径遍历攻击防护(../路径被拒绝)', () => {
  265. it('应该拒绝包含 ".." 的路径(路径遍历攻击防护)', async () => {
  266. // Arrange & Act & Assert
  267. await expect(
  268. uploadFileToField(mockPage, 'photo-upload', '../../../etc/passwd')
  269. ).rejects.toThrow(E2ETestError);
  270. });
  271. it('应该拒绝包含 ".." 的子目录路径', async () => {
  272. // Arrange & Act & Assert
  273. await expect(
  274. uploadFileToField(mockPage, 'photo-upload', 'images/../../etc/passwd')
  275. ).rejects.toThrow(E2ETestError);
  276. });
  277. it('应该拒绝包含 ".." 的相对路径', async () => {
  278. // Arrange & Act & Assert
  279. await expect(
  280. uploadFileToField(mockPage, 'photo-upload', '../test.jpg')
  281. ).rejects.toThrow(E2ETestError);
  282. });
  283. });
  284. describe('Subtask 4.2: 绝对路径被拒绝', () => {
  285. it('应该拒绝绝对路径(Linux)', async () => {
  286. // Arrange & Act & Assert
  287. await expect(
  288. uploadFileToField(mockPage, 'photo-upload', '/etc/passwd')
  289. ).rejects.toThrow(E2ETestError);
  290. });
  291. it('应该拒绝绝对路径(Windows)', { skip: process.platform !== 'win32' }, async () => {
  292. // 注意:此测试仅在 Windows 平台上运行
  293. // 在 Linux 上,Node.js 的 path.isAbsolute() 不识别 Windows 路径格式
  294. // Arrange & Act & Assert
  295. await expect(
  296. uploadFileToField(mockPage, 'photo-upload', 'C:\\Windows\\System32\\config')
  297. ).rejects.toThrow(E2ETestError);
  298. });
  299. it('路径遍历错误应该包含安全建议', async () => {
  300. // Arrange & Act & Assert
  301. try {
  302. await uploadFileToField(mockPage, 'photo-upload', '/etc/passwd');
  303. expect.fail('应该抛出错误');
  304. } catch (error) {
  305. expect(error).toBeInstanceOf(E2ETestError);
  306. const e2eError = error as E2ETestError;
  307. expect(e2eError.context.suggestion).toBeDefined();
  308. expect(e2eError.context.suggestion).toContain('fixtures');
  309. }
  310. });
  311. });
  312. describe('Subtask 4.3: 路径遍历验证(解析后的路径在 fixtures 目录内)', () => {
  313. it('应该验证解析后的路径在 fixtures 目录内(防止路径遍历)', async () => {
  314. // 此测试验证 resolveFixturePath 的安全检查
  315. // 路径如 "images/../../../etc/passwd" 会被拒绝
  316. // 即使经过 path.normalize 处理后
  317. // Arrange & Act & Assert
  318. await expect(
  319. uploadFileToField(mockPage, 'photo-upload', 'images/../../../etc/passwd')
  320. ).rejects.toThrow(E2ETestError);
  321. });
  322. it('应该拒绝路径遍历绕过尝试', async () => {
  323. // 尝试使用 ./ 绕过
  324. await expect(
  325. uploadFileToField(mockPage, 'photo-upload', './../../../etc/passwd')
  326. ).rejects.toThrow(E2ETestError);
  327. });
  328. });
  329. describe('Subtask 4.4: 超时配置生效', () => {
  330. it('应该使用自定义超时配置', async () => {
  331. // Arrange
  332. const customTimeout = 10000;
  333. // Act
  334. await uploadFileToField(mockPage, 'photo-upload', 'test.jpg', {
  335. timeout: customTimeout
  336. });
  337. // Assert
  338. expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
  339. expect.anything(),
  340. { timeout: customTimeout }
  341. );
  342. });
  343. it('应该使用默认超时配置(DEFAULT_TIMEOUTS.static)', async () => {
  344. // Arrange & Act
  345. await uploadFileToField(mockPage, 'photo-upload', 'test.jpg');
  346. // Assert
  347. expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
  348. expect.anything(),
  349. { timeout: DEFAULT_TIMEOUTS.static }
  350. );
  351. });
  352. });
  353. });
  354. describe('Task 4+: 边界条件和额外安全测试', () => {
  355. describe('Subtask 4.5: 边界条件测试', () => {
  356. it('应该拒绝空文件名(文件不存在)', async () => {
  357. // 空文件名会导致无效路径,文件不存在检查应该失败
  358. mockExistsSync.mockReturnValue(false);
  359. await expect(
  360. uploadFileToField(mockPage, 'photo-upload', '')
  361. ).rejects.toThrow(E2ETestError);
  362. });
  363. it('应该拒绝只包含空白的文件名', async () => {
  364. // 只包含空白的文件名会被 path.normalize 处理,但文件不存在
  365. mockExistsSync.mockReturnValue(false);
  366. await expect(
  367. uploadFileToField(mockPage, 'photo-upload', ' ')
  368. ).rejects.toThrow(E2ETestError);
  369. });
  370. it('应该处理带特殊字符的文件名(如果文件存在则接受)', async () => {
  371. // 函数不验证文件名模式,只检查文件是否存在
  372. // 如果文件存在,则接受该文件名
  373. const fileNameWithSpecialChars = 'test@#$file.jpg';
  374. mockExistsSync.mockReturnValue(true);
  375. await uploadFileToField(mockPage, 'photo-upload', fileNameWithSpecialChars);
  376. expect(mockLocator.setInputFiles).toHaveBeenCalled();
  377. });
  378. it('应该处理超长文件名(如果文件存在则接受)', async () => {
  379. // 函数不验证文件名长度,只检查文件是否存在
  380. // 实际的文件系统限制会在创建文件时生效
  381. const longFileName = 'a'.repeat(200) + '.jpg';
  382. mockExistsSync.mockReturnValue(true);
  383. await uploadFileToField(mockPage, 'photo-upload', longFileName);
  384. expect(mockLocator.setInputFiles).toHaveBeenCalled();
  385. });
  386. it('应该拒绝超长路径(文件不存在)', async () => {
  387. // 超长路径可能导致文件不存在
  388. const veryLongFileName = 'a'.repeat(300) + '.jpg';
  389. mockExistsSync.mockReturnValue(false);
  390. await expect(
  391. uploadFileToField(mockPage, 'photo-upload', veryLongFileName)
  392. ).rejects.toThrow(E2ETestError);
  393. });
  394. });
  395. describe('Subtask 4.6: 路径遍历安全验证测试', () => {
  396. it('应该拒绝路径遍历:sub/../../../etc/passwd', async () => {
  397. // path.normalize("sub/../../../etc/passwd") = "../../etc/passwd"
  398. // 规范化后以 ".." 开头,应该被拒绝
  399. await expect(
  400. uploadFileToField(mockPage, 'photo-upload', 'sub/../../../etc/passwd')
  401. ).rejects.toThrow(E2ETestError);
  402. });
  403. it('应该拒绝路径遍历:a/b/../../../../../etc/passwd', async () => {
  404. // path.normalize 规范化为 "../../../etc/passwd"
  405. await expect(
  406. uploadFileToField(mockPage, 'photo-upload', 'a/b/../../../../../etc/passwd')
  407. ).rejects.toThrow(E2ETestError);
  408. });
  409. it('应该拒绝路径遍历:./../../../etc/passwd', async () => {
  410. // path.normalize 规范化为 "../../../etc/passwd"
  411. await expect(
  412. uploadFileToField(mockPage, 'photo-upload', './../../../etc/passwd')
  413. ).rejects.toThrow(E2ETestError);
  414. });
  415. it('应该接受有效的子目录路径', async () => {
  416. // 验证正常子目录路径仍然有效
  417. // 这个测试确保我们没有过度限制合法的文件路径
  418. mockExistsSync.mockReturnValue(true);
  419. await uploadFileToField(mockPage, 'photo-upload', 'images/sample.jpg');
  420. expect(mockLocator.setInputFiles).toHaveBeenCalled();
  421. });
  422. });
  423. });
  424. describe('其他配置选项测试', () => {
  425. it('应该支持 waitForUpload: false(不等待上传完成)', async () => {
  426. // Arrange
  427. const fileName = 'test.jpg';
  428. const selector = 'file-upload';
  429. // Act
  430. await uploadFileToField(mockPage, selector, fileName, {
  431. waitForUpload: false
  432. });
  433. // Assert
  434. expect(mockPage.waitForTimeout).not.toHaveBeenCalled();
  435. expect(mockLocator.setInputFiles).toHaveBeenCalled();
  436. });
  437. it('应该同时支持多个自定义选项', async () => {
  438. // Arrange
  439. const customTimeout = 8000;
  440. const customFixturesDir = 'custom/fixtures';
  441. // Act
  442. await uploadFileToField(mockPage, 'file-input', 'test.pdf', {
  443. timeout: customTimeout,
  444. fixturesDir: customFixturesDir,
  445. waitForUpload: false
  446. });
  447. // Assert
  448. expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
  449. expect.stringContaining(customFixturesDir),
  450. { timeout: customTimeout }
  451. );
  452. expect(mockPage.waitForTimeout).not.toHaveBeenCalled();
  453. });
  454. });
  455. describe('Task 5: 多文件上传测试', () => {
  456. describe('Subtask 5.1: 单文件上传(向后兼容)', () => {
  457. it('应该保持向后兼容(单文件上传仍然工作)', async () => {
  458. // Arrange
  459. const fileName = 'sample-id-card.jpg';
  460. const selector = 'photo-upload';
  461. // Act
  462. await uploadFileToField(mockPage, selector, fileName);
  463. // Assert
  464. expect(mockPage.locator).toHaveBeenCalledWith(selector);
  465. expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
  466. expect.stringContaining(fileName),
  467. { timeout: DEFAULT_TIMEOUTS.static }
  468. );
  469. });
  470. it('单文件上传应该传递字符串参数给 setInputFiles', async () => {
  471. // Arrange
  472. const fileName = 'test.jpg';
  473. // Act
  474. await uploadFileToField(mockPage, 'input', fileName);
  475. // Assert
  476. const filePathArg = mockLocator.setInputFiles.mock.calls[0][0];
  477. expect(typeof filePathArg).toBe('string');
  478. expect(filePathArg).toContain(fileName);
  479. });
  480. });
  481. describe('Subtask 5.2: 多文件上传(2-3 个文件)', () => {
  482. it('应该成功上传多个文件(3个文件)', async () => {
  483. // Arrange
  484. const fileNames = [
  485. 'images/sample-id-card.jpg',
  486. 'images/sample-disability-card.jpg',
  487. 'images/sample-photo.jpg'
  488. ];
  489. const selector = 'photo-upload';
  490. // Act
  491. await uploadFileToField(mockPage, selector, fileNames);
  492. // Assert
  493. expect(mockPage.locator).toHaveBeenCalledWith(selector);
  494. expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
  495. expect.any(Array),
  496. { timeout: DEFAULT_TIMEOUTS.static }
  497. );
  498. // 验证传入的是文件路径数组
  499. const filePathsArg = mockLocator.setInputFiles.mock.calls[0][0];
  500. expect(Array.isArray(filePathsArg)).toBe(true);
  501. expect(filePathsArg).toHaveLength(3);
  502. });
  503. it('应该为每个文件解析正确的路径', async () => {
  504. // Arrange
  505. const fileNames = ['test1.jpg', 'test2.jpg'];
  506. // Act
  507. await uploadFileToField(mockPage, 'input', fileNames);
  508. // Assert
  509. const filePathsArg = mockLocator.setInputFiles.mock.calls[0][0];
  510. expect(filePathsArg[0]).toContain('test1.jpg');
  511. expect(filePathsArg[1]).toContain('test2.jpg');
  512. });
  513. it('应该支持两个文件的上传', async () => {
  514. // Arrange
  515. const fileNames = ['front.jpg', 'back.jpg'];
  516. // Act
  517. await uploadFileToField(mockPage, 'input', fileNames);
  518. // Assert
  519. const filePathsArg = mockLocator.setInputFiles.mock.calls[0][0];
  520. expect(filePathsArg).toHaveLength(2);
  521. });
  522. });
  523. describe('Subtask 5.3: 文件不存在错误(包含路径列表)', () => {
  524. it('应该在多文件中有文件不存在时抛出错误', async () => {
  525. // Arrange
  526. mockExistsSync.mockImplementation((path: string) => {
  527. // 只有第一个文件存在
  528. return path.includes('exist.jpg');
  529. });
  530. const fileNames = ['exist.jpg', 'missing.jpg', 'also-missing.jpg'];
  531. // Act & Assert
  532. await expect(
  533. uploadFileToField(mockPage, 'input', fileNames)
  534. ).rejects.toThrow(E2ETestError);
  535. });
  536. it('多文件错误消息应该列出所有缺失文件', async () => {
  537. // Arrange
  538. mockExistsSync.mockImplementation((path: string) => {
  539. // 只有 exist.jpg 存在
  540. return path.includes('exist.jpg');
  541. });
  542. const fileNames = ['exist.jpg', 'missing1.jpg', 'missing2.jpg'];
  543. // Act & Assert
  544. try {
  545. await uploadFileToField(mockPage, 'input', fileNames);
  546. expect.fail('应该抛出错误');
  547. } catch (error) {
  548. expect(error).toBeInstanceOf(E2ETestError);
  549. const e2eError = error as E2ETestError;
  550. expect(e2eError.context.actual).toContain('missing1.jpg');
  551. expect(e2eError.context.actual).toContain('missing2.jpg');
  552. // 错误消息应该包含可用文件
  553. expect(e2eError.context.suggestion).toContain('exist.jpg');
  554. }
  555. });
  556. it('应该在所有文件都不存在时列出所有缺失文件', async () => {
  557. // Arrange
  558. mockExistsSync.mockReturnValue(false);
  559. const fileNames = ['missing1.jpg', 'missing2.jpg', 'missing3.jpg'];
  560. // Act & Assert
  561. try {
  562. await uploadFileToField(mockPage, 'input', fileNames);
  563. expect.fail('应该抛出错误');
  564. } catch (error) {
  565. expect(error).toBeInstanceOf(E2ETestError);
  566. const e2eError = error as E2ETestError;
  567. expect(e2eError.context.actual).toContain('missing1.jpg');
  568. expect(e2eError.context.actual).toContain('missing2.jpg');
  569. expect(e2eError.context.actual).toContain('missing3.jpg');
  570. }
  571. });
  572. });
  573. describe('Subtask 5.4: 空数组错误处理', () => {
  574. it('应该在空数组时抛出错误', async () => {
  575. // Arrange
  576. const emptyFileNames: string[] = [];
  577. // Act & Assert
  578. await expect(
  579. uploadFileToField(mockPage, 'input', emptyFileNames)
  580. ).rejects.toThrow(E2ETestError);
  581. });
  582. it('空数组错误消息应该包含清晰的提示', async () => {
  583. // Arrange
  584. const emptyFileNames: string[] = [];
  585. // Act & Assert
  586. try {
  587. await uploadFileToField(mockPage, 'input', emptyFileNames);
  588. expect.fail('应该抛出错误');
  589. } catch (error) {
  590. expect(error).toBeInstanceOf(E2ETestError);
  591. const e2eError = error as E2ETestError;
  592. expect(e2eError.context.operation).toBe('uploadFileToField');
  593. expect(e2eError.context.suggestion).toContain('不能为空');
  594. }
  595. });
  596. });
  597. describe('多文件上传配置选项', () => {
  598. it('多文件上传应该支持自定义 fixtures 目录', async () => {
  599. // Arrange
  600. const customFixturesDir = 'custom/fixtures';
  601. const fileNames = ['test1.jpg', 'test2.jpg'];
  602. // Act
  603. await uploadFileToField(mockPage, 'input', fileNames, {
  604. fixturesDir: customFixturesDir
  605. });
  606. // Assert
  607. const filePathsArg = mockLocator.setInputFiles.mock.calls[0][0];
  608. expect(filePathsArg[0]).toContain(customFixturesDir);
  609. expect(filePathsArg[1]).toContain(customFixturesDir);
  610. });
  611. it('多文件上传应该支持自定义超时', async () => {
  612. // Arrange
  613. const customTimeout = 8000;
  614. const fileNames = ['test1.jpg', 'test2.jpg'];
  615. // Act
  616. await uploadFileToField(mockPage, 'input', fileNames, {
  617. timeout: customTimeout
  618. });
  619. // Assert
  620. expect(mockLocator.setInputFiles).toHaveBeenCalledWith(
  621. expect.any(Array),
  622. { timeout: customTimeout }
  623. );
  624. });
  625. it('多文件上传应该支持 waitForUpload: false', async () => {
  626. // Arrange
  627. const fileNames = ['test1.jpg', 'test2.jpg'];
  628. // Act
  629. await uploadFileToField(mockPage, 'input', fileNames, {
  630. waitForUpload: false
  631. });
  632. // Assert
  633. expect(mockPage.waitForTimeout).not.toHaveBeenCalled();
  634. });
  635. });
  636. describe('多文件上传错误处理增强', () => {
  637. it('选择器错误应该包含多文件支持提示', async () => {
  638. // Arrange
  639. mockLocator.setInputFiles.mockRejectedValue(
  640. new Error('Element not found')
  641. );
  642. const fileNames = ['test1.jpg', 'test2.jpg'];
  643. // Act & Assert
  644. try {
  645. await uploadFileToField(mockPage, 'bad-selector', fileNames);
  646. expect.fail('应该抛出错误');
  647. } catch (error) {
  648. expect(error).toBeInstanceOf(E2ETestError);
  649. const e2eError = error as E2ETestError;
  650. expect(e2eError.context.suggestion).toContain('multiple');
  651. }
  652. });
  653. it('多文件路径遍历攻击应该被拒绝(第一个文件)', async () => {
  654. // Arrange & Act & Assert
  655. await expect(
  656. uploadFileToField(mockPage, 'input', ['../../../etc/passwd', 'test.jpg'])
  657. ).rejects.toThrow(E2ETestError);
  658. });
  659. it('多文件路径遍历攻击应该被拒绝(第二个文件)', async () => {
  660. // Arrange & Act & Assert
  661. await expect(
  662. uploadFileToField(mockPage, 'input', ['test.jpg', '../../../etc/passwd'])
  663. ).rejects.toThrow(E2ETestError);
  664. });
  665. });
  666. });
  667. describe('主导出验证 (index.ts)', () => {
  668. it('应该正确导出 uploadFileToField 函数', () => {
  669. expect(uploadFileToField).toBeDefined();
  670. expect(typeof uploadFileToField).toBe('function');
  671. });
  672. it('应该正确导出 FileUploadOptions 类型', () => {
  673. const options: FileUploadOptions = {
  674. timeout: 10000,
  675. fixturesDir: 'custom/fixtures',
  676. waitForUpload: false
  677. };
  678. expect(options).toBeDefined();
  679. });
  680. });
  681. });