Status: ready-for-dev
作为测试开发者,
我想要使用 selectRadixOptionAsync() 函数选择异步加载的下拉框,
以便测试省份、城市等动态加载的选项。
Given 静态 Select 函数已实现(Story 1.3 完成)
When 实现 selectRadixOptionAsync(page, label, value, options?) 函数
Then 支持 AsyncSelectOptions 配置(timeout, waitForOption)
And 使用 waitForLoadState('networkidle') 等待异步加载
And 默认超时 5 秒,可配置
And 超时时提供清晰错误消息(标签、期望值、超时时间、可能原因)
And 所有导出函数有完整的 JSDoc 注释
src/radix-select.ts - 异步 Select 工具函数 (AC: 1, 2, 3, 4, 5)
selectRadixOptionAsync(page, label, value, options?) 主函数findTrigger 逻辑查找下拉框触发器src/index.ts 导出新增函数 (AC: 6)pnpm typecheck 确保无类型错误Epic 1 目标: 测试开发者可以安装 @d8d/e2e-test-utils 包,立即使用 Select 工具测试 Radix UI Select 组件。
本故事在 Epic 中的位置: 第四个故事,实现异步 Select 工具函数。这是在静态 Select 基础上的扩展,处理动态加载选项的场景。
从架构文档中必须遵循的决策:
API 设计模式:4 个参数(包含可选配置对象)
page, label, valueoptions?: AsyncSelectOptions类型定义:已在 Story 1.2 中定义
export interface AsyncSelectOptions extends BaseOptions {
/** 是否等待选项加载完成(默认:true)*/
waitForOption?: boolean;
/** 等待网络空闲后再操作(默认:false)*/
waitForNetworkIdle?: boolean;
}
错误处理策略:超时错误需要特殊处理
E2ETestError 类保持错误格式一致src/radix-select.ts - 异步 Select 工具函数实现指南:
/**
* 选择 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 - 可选配置
* @throws {E2ETestError} 当触发器未找到或等待超时时
*
* @example
* ```ts
* // 选择省份(异步加载)
* await selectRadixOptionAsync(page, '省份', '广东省');
*
* // 选择城市(自定义超时)
* await selectRadixOptionAsync(page, '城市', '深圳市', {
* timeout: 10000,
* waitForNetworkIdle: true
* });
* ```
*/
export async function selectRadixOptionAsync(
page: Page,
label: string,
value: string,
options?: AsyncSelectOptions
): Promise<void> {
// TODO: 实现异步选项选择逻辑
}
异步等待策略实现:
/**
* 等待异步选项加载并完成选择
*
* @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();
// 等待选项出现(使用重试机制)
while (Date.now() - startTime < timeout) {
try {
// 尝试查找选项
const option = await page.waitForSelector(
`[role="option"][data-value="${value}"]`,
{ timeout: 1000, state: 'visible' }
);
if (option) {
await option.click();
return; // 成功选择
}
} catch {
// 选项还未出现,继续等待
}
// 等待一小段时间后重试
await page.waitForTimeout(100);
}
// 超时:获取当前可用的选项用于错误提示
const availableOptions = await page.locator('[role="option"]').allTextContents();
throwError({
operation: 'selectRadixOptionAsync',
target: `选项 "${value}"`,
expected: `在 ${timeout}ms 内加载`,
available: availableOptions,
suggestion: '检查网络请求是否正常,或增加超时时间'
});
}
主函数完整实现:
export async function selectRadixOptionAsync(
page: Page,
label: string,
value: string,
options?: AsyncSelectOptions
): Promise<void> {
// 1. 合并默认配置
const config = {
timeout: options?.timeout ?? DEFAULT_TIMEOUTS.async,
waitForOption: options?.waitForOption ?? true,
waitForNetworkIdle: options?.waitForNetworkIdle ?? false
};
// 2. 查找触发器(复用静态 Select 的逻辑)
const trigger = await findTrigger(page, label, value);
// 3. 点击触发器展开选项列表
await trigger.click();
// 4. 等待选项列表容器出现
await page.waitForSelector('[role="listbox"]', {
timeout: DEFAULT_TIMEOUTS.static,
state: 'visible'
});
// 5. 可选:等待网络空闲(处理大量数据加载)
if (config.waitForNetworkIdle) {
try {
await page.waitForLoadState('networkidle', { timeout: config.timeout });
} catch (err) {
console.debug('网络空闲等待超时,继续尝试选择选项', err);
}
}
// 6. 等待选项出现并选择
await waitForOptionAndSelect(page, value, config.timeout);
}
Story 1.3 已完成的工作:
selectRadixOption() 已实现findTrigger() 已实现findAndClickOption() 已实现本故事需要做的:
findTrigger() 函数查找下拉框触发器waitForOptionAndSelect() 函数处理异步等待selectRadixOptionAsync() 主函数waitForLoadState)src/index.ts 导出新函数从 Story 1.3 代码审查中学习到的经验:
DOM 类型问题解决: 使用 page.locator().allTextContents() 替代 page.evaluate() 避免 TypeScript DOM 类型问题
精确文本匹配: 选项选择器使用 :text-is() 而非 :has-text() 避免部分匹配误选
空 catch 块处理: 添加 console.debug 改善调试体验
等待策略优化: 所有 waitForSelector 调用添加 state: "visible"
错误上下文完整性: 确保错误上下文包含所有必要参数
| 特性 | 静态 Select | 异步 Select |
|---|---|---|
| 选项加载时机 | 页面加载时已存在 | 点击触发器后 API 加载 |
| 等待策略 | 立即查找选项 | 等待网络请求 + 选项出现 |
| 默认超时 | 2000ms | 5000ms |
| 错误类型 | 选项未找到 | 等待超时 |
| 配置对象 | 无 | AsyncSelectOptions |
异步 Select 的 DOM 结构与静态 Select 相同:
<!-- 触发器 -->
<button
data-testid="省份-trigger"
role="combobox"
aria-label="省份"
>
省份
</button>
<!-- 选项列表(初始为空或显示加载状态) -->
<div role="listbox">
<!-- API 请求完成后动态添加选项 -->
<div role="option" data-value="guangdong">广东省</div>
<div role="option" data-value="beijing">北京市</div>
<div role="option" data-value="shanghai">上海市</div>
</div>
关键区别:
等待网络空闲:
// 等待网络空闲(所有网络请求完成)
await page.waitForLoadState('networkidle', { timeout: 5000 });
// 等待特定类型的加载状态
await page.waitForLoadState('domcontentloaded'); // DOM 加载完成
await page.waitForLoadState('load'); // 页面 load 事件
await page.waitForLoadState('networkidle'); // 网络空闲(至少 500ms 无网络活动)
重试模式:
// 使用重试机制等待异步元素
const maxRetries = 10;
const retryDelay = 500;
for (let i = 0; i < maxRetries; i++) {
try {
const element = await page.waitForSelector(selector, { timeout: 1000 });
await element.click();
break; // 成功,退出循环
} catch {
if (i < maxRetries - 1) {
await page.waitForTimeout(retryDelay);
} else {
throw new Error('重试失败');
}
}
}
与项目标准对齐:
docs/standards/testing-standards.md 中的测试规范docs/standards/e2e-radix-testing.md 中的 Radix UI E2E 测试标准docs/standards/coding-standards.md 中的编码标准TypeScript 配置:
strict: true),禁止 any 类型?: 标记JSDoc 注释标准:
@param, @throws, @example 标签@internal 标记命名约定:
Async 后缀waitForOptionAndSelect)@internal JSDoc 标记从 NFR 提取的性能要求:
实现时需要考虑:
DEFAULT_TIMEOUTS.async (5000ms) 作为默认超时waitForTimeoutwaitForSelector 而不是 waitForTimeout必须遵循的文件结构:
packages/e2e-test-utils/src/
├── index.ts # 主导出(需要更新)
├── types.ts # 共享类型定义(已完成,AsyncSelectOptions 已存在)
├── errors.ts # 错误类(已完成)
├── constants.ts # 常量定义(已完成,DEFAULT_TIMEOUTS.async 已存在)
└── radix-select.ts # Radix UI Select 工具(本故事修改,添加异步函数)
禁止事项(Anti-Patterns):
any 类型DEFAULT_TIMEOUTS.async)Error(必须使用 throwError 辅助函数)while(true) 循环(必须有超时限制)page.waitForTimeout(5000) 作为主要等待策略(应该使用重试机制)单元测试(在 Story 1.6 中实现):
本故事创建的 selectRadixOptionAsync 函数将在 Story 1.6 中进行单元测试。
当前验证方法:
pnpm typecheck对齐项目 Monorepo 架构:
packages/e2e-test-utils/@d8d/e2e-test-utils@workspace:*@d8d/shared-test-util(后端集成测试)分离与项目标准对齐:
docs/standards/testing-standards.md 中的测试规范docs/standards/web-ui-testing-standards.md 中的 Web UI 测试规范docs/standards/e2e-radix-testing.md 中的 Radix UI E2E 测试标准(核心标准文档)PRD 来源:
Architecture 来源:
标准文档来源:
Epic 来源:
前一个故事:
Claude (d8d-model) via create-story workflow
实现建议:
findTrigger 函数查找触发器waitForOptionAndSelect 函数处理异步等待allTextContents、:text-is()、完整 JSDoc)本故事需要创建/修改的文件:
packages/e2e-test-utils/src/radix-select.ts - 添加异步 Select 工具函数(修改)packages/e2e-test-utils/src/index.ts - 更新导出(修改)相关文件(已在 Story 1.1、1.2、1.3 中完成,本故事使用):
packages/e2e-test-utils/src/types.ts - 共享类型定义(AsyncSelectOptions 已存在)packages/e2e-test-utils/src/errors.ts - 错误类和错误处理packages/e2e-test-utils/src/constants.ts - 超时和选择器策略常量(DEFAULT_TIMEOUTS.async 已存在)只读参考文件:
_bmad-output/implementation-artifacts/1-3-static-select-tool.md - 前一个故事(静态 Select 实现)_bmad-output/implementation-artifacts/1-2-implement-types-errors.md - 类型定义故事_bmad-output/planning-artifacts/epics.md - Epic 和故事定义_bmad-output/planning-artifacts/architecture.md - 架构决策和模式docs/standards/e2e-radix-testing.md - E2E Radix UI 测试标准