| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- 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<void> {
- 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
- // 这种情况处理: <generic>标签文本</generic><combobox role="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<HTMLElement>;
- 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<HTMLElement>;
- 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<void> {
- 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<HTMLElement>;
- 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<void> {
- 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<HTMLElement>;
- 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<HTMLElement>;
- 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: '检查网络请求是否正常,或增加超时时间'
- });
- }
|