radix-select.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. import type { Page } from "@playwright/test";
  2. import type { AsyncSelectOptions } from "./types";
  3. import { throwError } from "./errors";
  4. import { DEFAULT_TIMEOUTS } from "./constants";
  5. /**
  6. * 选择 Radix UI 下拉框的静态选项
  7. *
  8. * @description
  9. * 自动处理 Radix UI Select 的 DOM 结构和交互流程,无需手动理解组件结构。
  10. *
  11. * 支持的选择器策略(按优先级):
  12. * 1. `data-testid="${label}-trigger"` - 推荐,最稳定
  13. * 2. `aria-label="${label}"` + `role="combobox"` - 无障碍属性
  14. * 3. `text="${label}"` - 文本匹配(兜底)
  15. *
  16. * @param page - Playwright Page 对象
  17. * @param label - 下拉框的标签文本(用于定位触发器)
  18. * @param value - 要选择的选项值
  19. * @throws {E2ETestError} 当触发器或选项未找到时
  20. *
  21. * @example
  22. * ```ts
  23. * // 选择残疾类型
  24. * await selectRadixOption(page, "残疾类型", "视力残疾");
  25. *
  26. * // 选择性别
  27. * await selectRadixOption(page, "性别", "男");
  28. * ```
  29. */
  30. export async function selectRadixOption(page: Page, label: string, value: string): Promise<void> {
  31. console.debug(`[selectRadixOption] 开始选择: label="${label}", value="${value}"`);
  32. const trigger = await findTrigger(page, label, value);
  33. console.debug(`[selectRadixOption] 找到触发器,准备检查元素类型`);
  34. // 检测是否是原生 select 元素
  35. const element = "elementHandle" in trigger ? await trigger.elementHandle() : trigger;
  36. const tagName = element ? await element.evaluate(el => el.tagName.toLowerCase()) : "";
  37. const isNativeSelect = tagName === "select";
  38. console.debug(`[selectRadixOption] 元素类型: ${tagName}, 是否原生 select: ${isNativeSelect}`);
  39. if (isNativeSelect) {
  40. // 原生 select 元素,使用 selectOption API
  41. console.debug(`[selectRadixOption] 使用原生 select 方法`);
  42. await trigger.selectOption(value);
  43. console.debug(`[selectRadixOption] 选择完成`);
  44. return;
  45. }
  46. // Radix UI Select,点击展开选项列表
  47. console.debug(`[selectRadixOption] 使用 Radix UI Select 方法`);
  48. await trigger.click();
  49. console.debug(`[selectRadixOption] 已点击触发器,等待选项出现`);
  50. // 等待选项出现(使用 getByRole 查询 accessibility tree)
  51. await page.getByRole("option").first().waitFor({ state: "visible", timeout: 2000 });
  52. console.debug(`[selectRadixOption] 选项已出现`);
  53. const availableOptions = await page.getByRole("option").allTextContents();
  54. console.debug(`[selectRadixOption] 可用选项:`, availableOptions);
  55. await findAndClickOption(page, value, availableOptions);
  56. console.debug(`[selectRadixOption] 选择完成`);
  57. }
  58. /**
  59. * 查找 Radix UI Select 触发器
  60. *
  61. * @description
  62. * 按优先级尝试四种选择器策略查找触发器元素。
  63. *
  64. * @internal
  65. *
  66. * @param page - Playwright Page 对象
  67. * @param label - 下拉框标签
  68. * @param expectedValue - 期望选择的选项值(用于错误提示)
  69. * @returns 触发器元素
  70. * @throws {E2ETestError} 当触发器未找到时
  71. */
  72. async function findTrigger(page: Page, label: string, expectedValue: string) {
  73. const timeout = 2000; // 使用较短超时快速尝试多个策略
  74. const options = { timeout, state: "visible" as const };
  75. // 策略 1: data-testid
  76. const testIdSelector = `[data-testid="${label}-trigger"]`;
  77. try {
  78. return await page.waitForSelector(testIdSelector, options);
  79. } catch (err) {
  80. console.debug(`选择器策略1失败: ${testIdSelector}`, err);
  81. }
  82. // 策略 2: aria-label + role
  83. const ariaSelector = `[aria-label="${label}"][role='"combobox"']`;
  84. try {
  85. return await page.waitForSelector(ariaSelector, options);
  86. } catch (err) {
  87. console.debug(`选择器策略2失败: ${ariaSelector}`, err);
  88. }
  89. // 策略 3: role=combobox with accessible name (使用 getByRole 更可靠)
  90. console.debug(`选择器策略3: 尝试 getByRole(combobox, { name: "${label}" })`);
  91. try {
  92. const locator = page.getByRole("combobox", { name: label, exact: true });
  93. await locator.waitFor({ state: "visible", timeout });
  94. console.debug(`选择器策略3成功: 找到 combobox "${label}"`);
  95. return locator;
  96. } catch (err) {
  97. console.debug(`选择器策略3失败: getByRole(combobox, { name: "${label}" })`, err);
  98. }
  99. // 策略 4: 查找包含标签文本的元素,然后找到相邻的 combobox
  100. // 这种情况处理: <generic>标签文本</generic><combobox role="combobox">
  101. console.debug(`选择器策略4: 尝试相邻 combobox 查找`);
  102. try {
  103. // 使用 getByText 而不是 text= 选择器,更可靠地处理特殊字符
  104. const labelElement = page.getByText(label, { exact: true }).first();
  105. const labelCount = await labelElement.count();
  106. console.debug(`选择器策略4: 找到 ${labelCount} 个包含文本 "${label}" 的元素`);
  107. if (labelCount > 0) {
  108. // 尝试找同级的 combobox(Radix UI 结构)
  109. const parentLocator = labelElement.locator("..");
  110. const combobox = parentLocator.locator('[role="combobox"]').first();
  111. const comboboxCount = await combobox.count();
  112. console.debug(`选择器策略4: 找到 ${comboboxCount} 个相邻的 combobox`);
  113. if (comboboxCount > 0) {
  114. // 确保元素可见后再返回
  115. await combobox.waitFor({ state: "visible", timeout: 2000 });
  116. console.debug(`选择器策略4成功: 找到相邻 combobox "${label}"`);
  117. return combobox;
  118. }
  119. }
  120. } catch (err) {
  121. console.debug(`选择器策略4失败: 相邻 combobox 查找`, err);
  122. }
  123. // 策略 5: 处理标签和 * 分离的情况(如:城市 * 是两个元素)
  124. // 查找包含标签文本的元素,然后在同一容器中查找相邻的 combobox
  125. console.debug("选择器策略5: 尝试处理标签和 * 分离的情况");
  126. try {
  127. const labelElementLocator = page.getByText(label).first();
  128. const labelCount = await labelElementLocator.count();
  129. if (labelCount > 0) {
  130. const parent = labelElementLocator.locator("..");
  131. const allComboboxes = parent.locator("..").locator("..").locator("[role=\"combobox\"]");
  132. const allCount = await allComboboxes.count();
  133. console.debug("选择器策略5: 找到 " + allCount + " 个 combobox");
  134. for (let i = 0; i < allCount; i++) {
  135. const box = allComboboxes.nth(i);
  136. const isDisabled = await box.getAttribute("data-disabled");
  137. if (!isDisabled) {
  138. await box.waitFor({ state: "visible", timeout: 2000 });
  139. console.debug("选择器策略5成功: 找到启用的 combobox");
  140. return box;
  141. }
  142. }
  143. console.debug("选择器策略5: 所有 combobox 都被禁用");
  144. }
  145. } catch (err) {
  146. console.debug("选择器策略5失败:", err);
  147. }
  148. // 所有策略都失败
  149. throwError({
  150. operation: "selectRadixOption",
  151. target: label,
  152. expected: expectedValue,
  153. suggestion: "检查下拉框标签是否正确,或添加 data-testid 属性"
  154. });
  155. }
  156. /**
  157. * 查找并点击 Radix UI Select 选项
  158. *
  159. * @description
  160. * 使用 Playwright 的 getByRole 方法定位选项,比 waitForSelector 更可靠。
  161. * 按优先级尝试 data-value 和无障碍名称两种策略。
  162. *
  163. * @internal
  164. *
  165. * @param page - Playwright Page 对象
  166. * @param value - 选项值
  167. * @param availableOptions - 可用选项列表(用于错误提示)
  168. * @throws {E2ETestError} 当选项未找到时
  169. */
  170. async function findAndClickOption(
  171. page: Page,
  172. value: string,
  173. availableOptions: string[]
  174. ) {
  175. const timeout = 2000; // 使用较短超时快速尝试多个策略
  176. // 策略 1: 使用 getByRole 查找 option(推荐 - 使用 accessibility tree)
  177. try {
  178. console.debug(`选项选择器策略1: getByRole("option", { name: "${value}" })`);
  179. const option = page.getByRole("option", { name: value, exact: true });
  180. // 先等待元素附加到 DOM(不要求可见)
  181. await option.waitFor({ state: "attached", timeout });
  182. // 然后等待可见
  183. await option.waitFor({ state: "visible", timeout: 2000 });
  184. await option.click();
  185. // 等待下拉框关闭(选项消失)
  186. await page.waitForTimeout(500);
  187. // 等待所有选项消失(不仅仅是第一个)
  188. try {
  189. await page.waitForFunction(() => {
  190. const options = document.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>;
  191. return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent);
  192. }, { timeout: 5000 });
  193. await page.waitForTimeout(500);
  194. } catch {
  195. // 选项可能已经消失,继续执行
  196. }
  197. console.debug(`选项选择器策略1成功`);
  198. return;
  199. } catch (err) {
  200. console.debug(`选项选择器策略1失败:`, err);
  201. }
  202. // 策略 2: data-value 属性(精确匹配)
  203. try {
  204. console.debug(`选项选择器策略2: [role="option"][data-value="${value}"]`);
  205. const option = await page.waitForSelector(`[role="option"][data-value="${value}"]`, {
  206. timeout,
  207. state: "visible"
  208. });
  209. await option.click();
  210. // 等待下拉框关闭
  211. await page.waitForTimeout(500);
  212. // 等待所有选项消失
  213. try {
  214. await page.waitForFunction(() => {
  215. const options = document.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>;
  216. return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent);
  217. }, { timeout: 5000 });
  218. await page.waitForTimeout(500);
  219. } catch {
  220. // 选项可能已经消失,继续执行
  221. }
  222. console.debug(`选项选择器策略2成功`);
  223. return;
  224. } catch (err) {
  225. console.debug(`选项选择器策略2失败:`, err);
  226. }
  227. // 未找到选项
  228. throwError({
  229. operation: "selectRadixOption",
  230. target: `选项 "${value}"`,
  231. available: availableOptions,
  232. suggestion: "检查选项值是否正确,或确认选项已加载到 DOM 中"
  233. });
  234. }
  235. /**
  236. * 选择 Radix UI 下拉框的异步加载选项
  237. *
  238. * @description
  239. * 用于选择通过 API 异步加载的 Radix UI Select 选项。
  240. * 默认自动等待网络请求完成和选项出现在 DOM 中。
  241. *
  242. * 支持的选择器策略(按优先级):
  243. * 1. `data-testid="${label}-trigger"` - 推荐,最稳定
  244. * 2. `aria-label="${label}"` + `role="combobox"` - 无障碍属性
  245. * 3. `text="${label}"` - 文本匹配(兜底)
  246. *
  247. * @param page - Playwright Page 对象
  248. * @param label - 下拉框的标签文本(用于定位触发器)
  249. * @param value - 要选择的选项值
  250. * @param options - 可选配置
  251. * @param options.timeout - 超时时间(毫秒),默认 5000ms
  252. * @param options.waitForOption - 是否等待选项加载完成(默认:true)
  253. * @param options.waitForNetworkIdle - 是否等待网络空闲后再操作(默认:true)
  254. * @throws {E2ETestError} 当触发器未找到或等待超时时
  255. *
  256. * @example
  257. * ```ts
  258. * // 选择省份(异步加载,默认等待网络空闲)
  259. * await selectRadixOptionAsync(page, '省份', '广东省');
  260. *
  261. * // 选择城市(自定义超时,禁用网络空闲等待)
  262. * await selectRadixOptionAsync(page, '城市', '深圳市', {
  263. * timeout: 30000,
  264. * waitForNetworkIdle: false
  265. * });
  266. * ```
  267. */
  268. export async function selectRadixOptionAsync(
  269. page: Page,
  270. label: string,
  271. value: string,
  272. options?: AsyncSelectOptions
  273. ): Promise<void> {
  274. console.debug(`[selectRadixOptionAsync] 开始选择: label="${label}", value="${value}"`);
  275. // 1. 合并默认配置
  276. const config = {
  277. timeout: options?.timeout ?? DEFAULT_TIMEOUTS.async,
  278. waitForOption: options?.waitForOption ?? true,
  279. waitForNetworkIdle: options?.waitForNetworkIdle ?? true
  280. };
  281. // 2. 查找触发器(复用静态 Select 的逻辑)
  282. const trigger = await findTrigger(page, label, value);
  283. console.debug(`[selectRadixOptionAsync] 找到触发器,准备检查元素类型`);
  284. // 3. 检测是否是原生 select 元素
  285. const element = "elementHandle" in trigger ? await trigger.elementHandle() : trigger;
  286. const tagName = element ? await element.evaluate(el => el.tagName.toLowerCase()) : "";
  287. const isNativeSelect = tagName === "select";
  288. console.debug(`[selectRadixOptionAsync] 元素类型: ${tagName}, 是否原生 select: ${isNativeSelect}`);
  289. if (isNativeSelect) {
  290. // 原生 select 元素,使用 selectOption API
  291. console.debug(`[selectRadixOptionAsync] 使用原生 select 方法`);
  292. await trigger.selectOption(value);
  293. console.debug(`[selectRadixOptionAsync] 选择完成`);
  294. return;
  295. }
  296. // 4. 确保之前的下拉框已完全关闭(级联选择场景)
  297. console.debug(`[selectRadixOptionAsync] 检查并等待之前的下拉框关闭`);
  298. try {
  299. await page.waitForFunction(() => {
  300. const options = document.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>;
  301. return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent);
  302. }, { timeout: 2000 });
  303. console.debug(`[selectRadixOptionAsync] 之前的下拉框已关闭`);
  304. } catch {
  305. // 没有之前的下拉框或已关闭,继续执行
  306. console.debug(`[selectRadixOptionAsync] 没有需要关闭的下拉框`);
  307. }
  308. // 5. 点击 Radix UI Select 触发器展开选项列表
  309. console.debug(`[selectRadixOptionAsync] 使用 Radix UI Select 方法`);
  310. await trigger.click();
  311. console.debug(`[selectRadixOptionAsync] 已点击触发器,等待选项出现`);
  312. // 5. 等待选项出现(Radix UI Select v2 没有 listbox,直接等待 option)
  313. // 选项在 Portal 中渲染,需要短暂等待
  314. await page.waitForTimeout(100);
  315. await page.getByRole("option").first().waitFor({
  316. state: "visible",
  317. timeout: 2000
  318. });
  319. console.debug(`[selectRadixOptionAsync] 选项已出现`);
  320. // 6. 等待网络空闲(处理大量数据加载)
  321. // 注意:网络空闲等待失败不会中断流程,因为某些场景下网络可能始终不空闲
  322. if (config.waitForNetworkIdle) {
  323. console.debug(`[selectRadixOptionAsync] 等待网络空闲 (timeout: ${config.timeout}ms)`);
  324. try {
  325. await page.waitForLoadState('networkidle', { timeout: config.timeout });
  326. console.debug(`[selectRadixOptionAsync] 网络空闲`);
  327. } catch (err) {
  328. console.debug('[selectRadixOptionAsync] 网络空闲等待超时,继续尝试选择选项', err);
  329. }
  330. }
  331. // 7. 等待选项出现并选择
  332. if (config.waitForOption) {
  333. console.debug(`[selectRadixOptionAsync] 等待选项加载 (timeout: ${config.timeout}ms)`);
  334. await waitForOptionAndSelect(page, value, config.timeout);
  335. } else {
  336. // 不等待选项,直接尝试选择(向后兼容)
  337. const availableOptions = await page.locator('[role="option"]').allTextContents();
  338. console.debug(`[selectRadixOptionAsync] 可用选项:`, availableOptions);
  339. await findAndClickOption(page, value, availableOptions);
  340. }
  341. console.debug(`[selectRadixOptionAsync] 选择完成`);
  342. }
  343. /**
  344. * 等待异步选项加载并完成选择
  345. *
  346. * @description
  347. * 使用重试机制等待异步加载的选项出现在 DOM 中,
  348. * 然后完成选择操作。
  349. *
  350. * 按优先级尝试两种选择器策略:
  351. * 1. getByRole("option", { name: value }) - 使用 accessibility tree
  352. * 2. data-value 属性(精确匹配)
  353. *
  354. * @internal
  355. *
  356. * @param page - Playwright Page 对象
  357. * @param value - 选项值
  358. * @param timeout - 超时时间(毫秒)
  359. * @throws {E2ETestError} 当等待超时时
  360. */
  361. async function waitForOptionAndSelect(
  362. page: Page,
  363. value: string,
  364. timeout: number
  365. ): Promise<void> {
  366. const startTime = Date.now();
  367. const retryInterval = 100; // 重试间隔(毫秒)
  368. // 级联选择场景:等待之前的选项完全消失,新选项有时间加载
  369. // 这是一个关键等待,确保网络请求有足够时间返回新选项
  370. console.debug(`[waitForOptionAndSelect] 等待新选项加载(初始等待 500ms)`);
  371. await page.waitForTimeout(500);
  372. // 等待选项出现(使用重试机制)
  373. while (Date.now() - startTime < timeout) {
  374. try {
  375. // 策略 1: getByRole("option", { name: value }) - 更可靠
  376. console.debug(`异步选项选择策略1: getByRole("option", { name: "${value}" })`);
  377. const option = page.getByRole("option", { name: value, exact: true });
  378. // 等待元素附加到 DOM
  379. await option.waitFor({ state: "attached", timeout: retryInterval });
  380. // 等待元素可见
  381. await option.waitFor({ state: "visible", timeout: 500 });
  382. await option.click();
  383. // 等待下拉框关闭
  384. await page.waitForTimeout(500);
  385. // 等待所有选项消失
  386. try {
  387. await page.waitForFunction(() => {
  388. const options = document.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>;
  389. return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent);
  390. }, { timeout: 5000 });
  391. await page.waitForTimeout(500);
  392. } catch {
  393. // 选项可能已经消失,继续执行
  394. }
  395. console.debug(`异步选项选择策略1成功`);
  396. return; // 成功选择
  397. } catch (err) {
  398. console.debug(`异步选项选择策略1失败:`, err);
  399. }
  400. // 等待一小段时间后重试
  401. try {
  402. await page.waitForTimeout(retryInterval);
  403. } catch {
  404. // waitForTimeout 可能被中断,忽略错误继续重试
  405. }
  406. }
  407. // 策略 2: 尝试 data-value 属性匹配(一次性尝试,不重试)
  408. try {
  409. console.debug(`异步选项选择策略2: [role="option"][data-value="${value}"]`);
  410. const option = await page.waitForSelector(`[role="option"][data-value="${value}"]`, {
  411. timeout: 2000,
  412. state: 'visible'
  413. });
  414. await option.click();
  415. // 等待下拉框关闭
  416. await page.waitForTimeout(500);
  417. // 等待所有选项消失
  418. try {
  419. await page.waitForFunction(() => {
  420. const options = document.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>;
  421. return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent);
  422. }, { timeout: 5000 });
  423. await page.waitForTimeout(500);
  424. } catch {
  425. // 选项可能已经消失,继续执行
  426. }
  427. console.debug(`异步选项选择策略2成功`);
  428. return; // 成功选择
  429. } catch {
  430. // data-value 策略也失败,继续抛出错误
  431. }
  432. // 超时:获取当前可用的选项用于错误提示
  433. const availableOptions = await page.locator('[role="option"]').allTextContents();
  434. throwError({
  435. operation: 'selectRadixOptionAsync',
  436. target: `选项 "${value}"`,
  437. expected: `在 ${timeout}ms 内加载`,
  438. available: availableOptions,
  439. suggestion: '检查网络请求是否正常,或增加超时时间'
  440. });
  441. }