import type { Page } from "@playwright/test"; import type { AsyncSelectOptions } from "./types"; import { throwError } from "./errors"; import { DEFAULT_TIMEOUTS } from "./constants"; /** * 选择 Radix UI 下拉框的静态选项 * * @description * 自动处理 Radix UI Select 的 DOM 结构和交互流程,无需手动理解组件结构。 * * 支持的选择器策略(按优先级): * 1. `data-testid="${label}-trigger"` - 推荐,最稳定 * 2. `aria-label="${label}"` + `role="combobox"` - 无障碍属性 * 3. `text="${label}"` - 文本匹配(兜底) * * @param page - Playwright Page 对象 * @param label - 下拉框的标签文本(用于定位触发器) * @param value - 要选择的选项值 * @throws {E2ETestError} 当触发器或选项未找到时 * * @example * ```ts * // 选择残疾类型 * await selectRadixOption(page, "残疾类型", "视力残疾"); * * // 选择性别 * await selectRadixOption(page, "性别", "男"); * ``` */ export async function selectRadixOption(page: Page, label: string, value: string): Promise { console.debug(`[selectRadixOption] 开始选择: label="${label}", value="${value}"`); const trigger = await findTrigger(page, label, value); console.debug(`[selectRadixOption] 找到触发器,准备检查元素类型`); // 检测是否是原生 select 元素 const element = "elementHandle" in trigger ? await trigger.elementHandle() : trigger; const tagName = element ? await element.evaluate(el => el.tagName.toLowerCase()) : ""; const isNativeSelect = tagName === "select"; console.debug(`[selectRadixOption] 元素类型: ${tagName}, 是否原生 select: ${isNativeSelect}`); if (isNativeSelect) { // 原生 select 元素,使用 selectOption API console.debug(`[selectRadixOption] 使用原生 select 方法`); await trigger.selectOption(value); console.debug(`[selectRadixOption] 选择完成`); return; } // Radix UI Select,点击展开选项列表 console.debug(`[selectRadixOption] 使用 Radix UI Select 方法`); await trigger.click(); console.debug(`[selectRadixOption] 已点击触发器,等待选项出现`); // 等待选项出现(使用 getByRole 查询 accessibility tree) await page.getByRole("option").first().waitFor({ state: "visible", timeout: 2000 }); console.debug(`[selectRadixOption] 选项已出现`); const availableOptions = await page.getByRole("option").allTextContents(); console.debug(`[selectRadixOption] 可用选项:`, availableOptions); await findAndClickOption(page, value, availableOptions); console.debug(`[selectRadixOption] 选择完成`); } /** * 查找 Radix UI Select 触发器 * * @description * 按优先级尝试四种选择器策略查找触发器元素。 * * @internal * * @param page - Playwright Page 对象 * @param label - 下拉框标签 * @param expectedValue - 期望选择的选项值(用于错误提示) * @returns 触发器元素 * @throws {E2ETestError} 当触发器未找到时 */ async function findTrigger(page: Page, label: string, expectedValue: string) { const timeout = 2000; // 使用较短超时快速尝试多个策略 const options = { timeout, state: "visible" as const }; // 策略 1: data-testid (标准格式: ${label}-trigger) const testIdSelector = `[data-testid="${label}-trigger"]`; try { return await page.waitForSelector(testIdSelector, options); } catch (err) { console.debug(`选择器策略1失败: ${testIdSelector}`, err); } // 策略 1.5: data-testid 部分匹配 (支持自定义格式如 platform-selector-create) // 查找包含标签名的 data-testid,且是按钮角色的元素 try { const partialTestIdSelector = `[data-testid*="${label}"][role="button"]`; const element = await page.waitForSelector(partialTestIdSelector, { timeout: timeout / 2 }); console.debug(`选择器策略1.5成功: 找到 ${partialTestIdSelector}`); return element; } catch (err) { const selectorStr = `[data-testid*="${label}"][role="button"]`; console.debug(`选择器策略1.5失败: ${selectorStr}`, err); } // 策略 2: aria-label + role const ariaSelector = `[aria-label="${label}"][role='"combobox"']`; try { return await page.waitForSelector(ariaSelector, options); } catch (err) { console.debug(`选择器策略2失败: ${ariaSelector}`, err); } // 策略 3: role=combobox with accessible name (使用 getByRole 更可靠) console.debug(`选择器策略3: 尝试 getByRole(combobox, { name: "${label}" })`); try { const locator = page.getByRole("combobox", { name: label, exact: true }); await locator.waitFor({ state: "visible", timeout }); console.debug(`选择器策略3成功: 找到 combobox "${label}"`); return locator; } catch (err) { console.debug(`选择器策略3失败: getByRole(combobox, { name: "${label}" })`, err); } // 策略 3.5: role=button with data-testid 包含标签名 (处理 shadcn/ui SelectTrigger) try { const locator = page.getByRole("button").filter({ hasText: label }).first(); await locator.waitFor({ state: "visible", timeout }); // 验证这个按钮确实是选择器触发器(检查它是否在表单中) const isInForm = await page.locator('form').locator(`role="button"`).filter({ hasText: label }).count() > 0; if (isInForm) { console.debug(`选择器策略3.5成功: 找到包含"${label}"的按钮`); return locator; } } catch (err) { console.debug(`选择器策略3.5失败: 查找包含"${label}"的按钮`, err); } // 策略 4: 查找包含标签文本的元素,然后找到相邻的 combobox // 这种情况处理: 标签文本 console.debug(`选择器策略4: 尝试相邻 combobox 查找`); try { // 使用 getByText 而不是 text= 选择器,更可靠地处理特殊字符 const labelElement = page.getByText(label, { exact: true }).first(); const labelCount = await labelElement.count(); console.debug(`选择器策略4: 找到 ${labelCount} 个包含文本 "${label}" 的元素`); if (labelCount > 0) { // 尝试找同级的 combobox(Radix UI 结构) const parentLocator = labelElement.locator(".."); const combobox = parentLocator.locator('[role="combobox"]').first(); const comboboxCount = await combobox.count(); console.debug(`选择器策略4: 找到 ${comboboxCount} 个相邻的 combobox`); if (comboboxCount > 0) { // 确保元素可见后再返回 await combobox.waitFor({ state: "visible", timeout: 2000 }); console.debug(`选择器策略4成功: 找到相邻 combobox "${label}"`); return combobox; } } } catch (err) { console.debug(`选择器策略4失败: 相邻 combobox 查找`, err); } // 策略 5: 处理标签和 * 分离的情况(如:城市 * 是两个元素) // 查找包含标签文本的元素,然后在同一容器中查找相邻的 combobox console.debug("选择器策略5: 尝试处理标签和 * 分离的情况"); try { const labelElementLocator = page.getByText(label).first(); const labelCount = await labelElementLocator.count(); if (labelCount > 0) { const parent = labelElementLocator.locator(".."); const allComboboxes = parent.locator("..").locator("..").locator("[role=\"combobox\"]"); const allCount = await allComboboxes.count(); console.debug("选择器策略5: 找到 " + allCount + " 个 combobox"); for (let i = 0; i < allCount; i++) { const box = allComboboxes.nth(i); const isDisabled = await box.getAttribute("data-disabled"); if (!isDisabled) { await box.waitFor({ state: "visible", timeout: 2000 }); console.debug("选择器策略5成功: 找到启用的 combobox"); return box; } } console.debug("选择器策略5: 所有 combobox 都被禁用"); } } catch (err) { console.debug("选择器策略5失败:", err); } // 所有策略都失败 throwError({ operation: "selectRadixOption", target: label, expected: expectedValue, suggestion: "检查下拉框标签是否正确,或添加 data-testid 属性" }); } /** * 查找并点击 Radix UI Select 选项 * * @description * 使用 Playwright 的 getByRole 方法定位选项,比 waitForSelector 更可靠。 * 按优先级尝试 data-value 和无障碍名称两种策略。 * * @internal * * @param page - Playwright Page 对象 * @param value - 选项值 * @param availableOptions - 可用选项列表(用于错误提示) * @throws {E2ETestError} 当选项未找到时 */ async function findAndClickOption( page: Page, value: string, availableOptions: string[] ) { const timeout = 2000; // 使用较短超时快速尝试多个策略 // 策略 1: 使用 getByRole 查找 option(推荐 - 使用 accessibility tree) try { console.debug(`选项选择器策略1: getByRole("option", { name: "${value}" })`); const option = page.getByRole("option", { name: value, exact: true }); // 先等待元素附加到 DOM(不要求可见) await option.waitFor({ state: "attached", timeout }); // 然后等待可见 await option.waitFor({ state: "visible", timeout: 2000 }); await option.click(); // 等待下拉框关闭(选项消失) await page.waitForTimeout(500); // 等待所有选项消失(不仅仅是第一个) try { await page.waitForFunction(() => { const options = document.querySelectorAll('[role="option"]') as NodeListOf; return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent); }, { timeout: 5000 }); await page.waitForTimeout(500); } catch { // 选项可能已经消失,继续执行 } console.debug(`选项选择器策略1成功`); return; } catch (err) { console.debug(`选项选择器策略1失败:`, err); } // 策略 2: data-value 属性(精确匹配) try { console.debug(`选项选择器策略2: [role="option"][data-value="${value}"]`); const option = await page.waitForSelector(`[role="option"][data-value="${value}"]`, { timeout, state: "visible" }); await option.click(); // 等待下拉框关闭 await page.waitForTimeout(500); // 等待所有选项消失 try { await page.waitForFunction(() => { const options = document.querySelectorAll('[role="option"]') as NodeListOf; return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent); }, { timeout: 5000 }); await page.waitForTimeout(500); } catch { // 选项可能已经消失,继续执行 } console.debug(`选项选择器策略2成功`); return; } catch (err) { console.debug(`选项选择器策略2失败:`, err); } // 未找到选项 throwError({ operation: "selectRadixOption", target: `选项 "${value}"`, available: availableOptions, suggestion: "检查选项值是否正确,或确认选项已加载到 DOM 中" }); } /** * 选择 Radix UI 下拉框的异步加载选项 * * @description * 用于选择通过 API 异步加载的 Radix UI Select 选项。 * 默认自动等待网络请求完成和选项出现在 DOM 中。 * * 支持的选择器策略(按优先级): * 1. `data-testid="${label}-trigger"` - 推荐,最稳定 * 2. `aria-label="${label}"` + `role="combobox"` - 无障碍属性 * 3. `text="${label}"` - 文本匹配(兜底) * * @param page - Playwright Page 对象 * @param label - 下拉框的标签文本(用于定位触发器) * @param value - 要选择的选项值 * @param options - 可选配置 * @param options.timeout - 超时时间(毫秒),默认 5000ms * @param options.waitForOption - 是否等待选项加载完成(默认:true) * @param options.waitForNetworkIdle - 是否等待网络空闲后再操作(默认:true) * @throws {E2ETestError} 当触发器未找到或等待超时时 * * @example * ```ts * // 选择省份(异步加载,默认等待网络空闲) * await selectRadixOptionAsync(page, '省份', '广东省'); * * // 选择城市(自定义超时,禁用网络空闲等待) * await selectRadixOptionAsync(page, '城市', '深圳市', { * timeout: 30000, * waitForNetworkIdle: false * }); * ``` */ export async function selectRadixOptionAsync( page: Page, label: string, value: string, options?: AsyncSelectOptions ): Promise { console.debug(`[selectRadixOptionAsync] 开始选择: label="${label}", value="${value}"`); // 1. 合并默认配置 const config = { timeout: options?.timeout ?? DEFAULT_TIMEOUTS.async, waitForOption: options?.waitForOption ?? true, waitForNetworkIdle: options?.waitForNetworkIdle ?? true }; // 2. 查找触发器(复用静态 Select 的逻辑) const trigger = await findTrigger(page, label, value); console.debug(`[selectRadixOptionAsync] 找到触发器,准备检查元素类型`); // 3. 检测是否是原生 select 元素 const element = "elementHandle" in trigger ? await trigger.elementHandle() : trigger; const tagName = element ? await element.evaluate(el => el.tagName.toLowerCase()) : ""; const isNativeSelect = tagName === "select"; console.debug(`[selectRadixOptionAsync] 元素类型: ${tagName}, 是否原生 select: ${isNativeSelect}`); if (isNativeSelect) { // 原生 select 元素,使用 selectOption API console.debug(`[selectRadixOptionAsync] 使用原生 select 方法`); await trigger.selectOption(value); console.debug(`[selectRadixOptionAsync] 选择完成`); return; } // 4. 确保之前的下拉框已完全关闭(级联选择场景) console.debug(`[selectRadixOptionAsync] 检查并等待之前的下拉框关闭`); try { await page.waitForFunction(() => { const options = document.querySelectorAll('[role="option"]') as NodeListOf; return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent); }, { timeout: 2000 }); console.debug(`[selectRadixOptionAsync] 之前的下拉框已关闭`); } catch { // 没有之前的下拉框或已关闭,继续执行 console.debug(`[selectRadixOptionAsync] 没有需要关闭的下拉框`); } // 5. 点击 Radix UI Select 触发器展开选项列表 console.debug(`[selectRadixOptionAsync] 使用 Radix UI Select 方法`); await trigger.click(); console.debug(`[selectRadixOptionAsync] 已点击触发器,等待选项出现`); // 5. 等待选项出现(Radix UI Select v2 没有 listbox,直接等待 option) // 选项在 Portal 中渲染,需要短暂等待 await page.waitForTimeout(100); await page.getByRole("option").first().waitFor({ state: "visible", timeout: 2000 }); console.debug(`[selectRadixOptionAsync] 选项已出现`); // 6. 等待网络空闲(处理大量数据加载) // 注意:网络空闲等待失败不会中断流程,因为某些场景下网络可能始终不空闲 if (config.waitForNetworkIdle) { console.debug(`[selectRadixOptionAsync] 等待网络空闲 (timeout: ${config.timeout}ms)`); try { await page.waitForLoadState('networkidle', { timeout: config.timeout }); console.debug(`[selectRadixOptionAsync] 网络空闲`); } catch (err) { console.debug('[selectRadixOptionAsync] 网络空闲等待超时,继续尝试选择选项', err); } } // 7. 等待选项出现并选择 if (config.waitForOption) { console.debug(`[selectRadixOptionAsync] 等待选项加载 (timeout: ${config.timeout}ms)`); await waitForOptionAndSelect(page, value, config.timeout); } else { // 不等待选项,直接尝试选择(向后兼容) const availableOptions = await page.locator('[role="option"]').allTextContents(); console.debug(`[selectRadixOptionAsync] 可用选项:`, availableOptions); await findAndClickOption(page, value, availableOptions); } console.debug(`[selectRadixOptionAsync] 选择完成`); } /** * 等待异步选项加载并完成选择 * * @description * 使用重试机制等待异步加载的选项出现在 DOM 中, * 然后完成选择操作。 * * 按优先级尝试两种选择器策略: * 1. getByRole("option", { name: value }) - 使用 accessibility tree * 2. data-value 属性(精确匹配) * * @internal * * @param page - Playwright Page 对象 * @param value - 选项值 * @param timeout - 超时时间(毫秒) * @throws {E2ETestError} 当等待超时时 */ async function waitForOptionAndSelect( page: Page, value: string, timeout: number ): Promise { const startTime = Date.now(); const retryInterval = 100; // 重试间隔(毫秒) // 级联选择场景:等待之前的选项完全消失,新选项有时间加载 // 这是一个关键等待,确保网络请求有足够时间返回新选项 console.debug(`[waitForOptionAndSelect] 等待新选项加载(初始等待 500ms)`); await page.waitForTimeout(500); // 等待选项出现(使用重试机制) while (Date.now() - startTime < timeout) { try { // 策略 1: getByRole("option", { name: value }) - 更可靠 console.debug(`异步选项选择策略1: getByRole("option", { name: "${value}" })`); const option = page.getByRole("option", { name: value, exact: true }); // 等待元素附加到 DOM await option.waitFor({ state: "attached", timeout: retryInterval }); // 等待元素可见 await option.waitFor({ state: "visible", timeout: 500 }); await option.click(); // 等待下拉框关闭 await page.waitForTimeout(500); // 等待所有选项消失 try { await page.waitForFunction(() => { const options = document.querySelectorAll('[role="option"]') as NodeListOf; return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent); }, { timeout: 5000 }); await page.waitForTimeout(500); } catch { // 选项可能已经消失,继续执行 } console.debug(`异步选项选择策略1成功`); return; // 成功选择 } catch (err) { console.debug(`异步选项选择策略1失败:`, err); } // 等待一小段时间后重试 try { await page.waitForTimeout(retryInterval); } catch { // waitForTimeout 可能被中断,忽略错误继续重试 } } // 策略 2: 尝试 data-value 属性匹配(一次性尝试,不重试) try { console.debug(`异步选项选择策略2: [role="option"][data-value="${value}"]`); const option = await page.waitForSelector(`[role="option"][data-value="${value}"]`, { timeout: 2000, state: 'visible' }); await option.click(); // 等待下拉框关闭 await page.waitForTimeout(500); // 等待所有选项消失 try { await page.waitForFunction(() => { const options = document.querySelectorAll('[role="option"]') as NodeListOf; return options.length === 0 || Array.from(options).every(opt => !opt.offsetParent); }, { timeout: 5000 }); await page.waitForTimeout(500); } catch { // 选项可能已经消失,继续执行 } console.debug(`异步选项选择策略2成功`); return; // 成功选择 } catch { // data-value 策略也失败,继续抛出错误 } // 超时:获取当前可用的选项用于错误提示 const availableOptions = await page.locator('[role="option"]').allTextContents(); throwError({ operation: 'selectRadixOptionAsync', target: `选项 "${value}"`, expected: `在 ${timeout}ms 内加载`, available: availableOptions, suggestion: '检查网络请求是否正常,或增加超时时间' }); }