radix-select.ts 20 KB

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