# Story 1.4: 实现异步 Select 工具函数 Status: done ## Story 作为测试开发者, 我想要使用 `selectRadixOptionAsync()` 函数选择异步加载的下拉框, 以便测试省份、城市等动态加载的选项。 ## Acceptance Criteria **Given** 静态 Select 函数已实现(Story 1.3 完成) **When** 实现 `selectRadixOptionAsync(page, label, value, options?)` 函数 **Then** 支持 `AsyncSelectOptions` 配置(timeout, waitForOption) **And** 使用 `waitForLoadState('networkidle')` 等待异步加载 **And** 默认超时 5 秒,可配置 **And** 超时时提供清晰错误消息(标签、期望值、超时时间、可能原因) **And** 所有导出函数有完整的 JSDoc 注释 ## Tasks / Subtasks - [x] 实现 `src/radix-select.ts` - 异步 Select 工具函数 (AC: 1, 2, 3, 4, 5) - [x] 实现 `selectRadixOptionAsync(page, label, value, options?)` 主函数 - [x] 复用 `findTrigger` 逻辑查找下拉框触发器 - [x] 实现异步选项等待逻辑(网络空闲 + 选项可见) - [x] 实现可配置超时机制(默认 5000ms) - [x] 添加超时错误处理(包含超时时间、可能原因) - [x] 为所有导出函数添加完整 JSDoc 注释 - [x] 更新 `src/index.ts` 导出新增函数 (AC: 6) - [x] 类型检查通过验证 - [x] 运行 `pnpm typecheck` 确保无类型错误 ## Dev Notes ### Epic 1 背景 **Epic 1 目标:** 测试开发者可以安装 `@d8d/e2e-test-utils` 包,立即使用 Select 工具测试 Radix UI Select 组件。 **本故事在 Epic 中的位置:** 第四个故事,实现异步 Select 工具函数。这是在静态 Select 基础上的扩展,处理动态加载选项的场景。 ### 架构约束和模式 **从架构文档中必须遵循的决策:** **API 设计模式:4 个参数(包含可选配置对象)** - 必需参数:`page`, `label`, `value` - 可选参数:`options?: AsyncSelectOptions` - 与静态 Select 保持一致的接口风格 **类型定义:已在 Story 1.2 中定义** ```typescript export interface AsyncSelectOptions extends BaseOptions { /** 是否等待选项加载完成(默认:true)*/ waitForOption?: boolean; /** 等待网络空闲后再操作(默认:false)*/ waitForNetworkIdle?: boolean; } ``` **错误处理策略:超时错误需要特殊处理** - 超时错误应包含:超时时间、期望值、可能原因 - 区分"选项未找到"和"等待超时"两种错误场景 - 使用 `E2ETestError` 类保持错误格式一致 ### 技术实现要求 **`src/radix-select.ts` - 异步 Select 工具函数实现指南:** ```typescript /** * 选择 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 { // TODO: 实现异步选项选择逻辑 } ``` **异步等待策略实现:** ```typescript /** * 等待异步选项加载并完成选择 * * @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(); // 等待选项出现(使用重试机制) 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: '检查网络请求是否正常,或增加超时时间' }); } ``` **主函数完整实现:** ```typescript export async function selectRadixOptionAsync( page: Page, label: string, value: string, options?: AsyncSelectOptions ): Promise { // 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 已完成的工作:** - ✅ 静态 Select 函数 `selectRadixOption()` 已实现 - ✅ 触发器查找逻辑 `findTrigger()` 已实现 - ✅ 选项查找逻辑 `findAndClickOption()` 已实现 - ✅ 选择器策略(testid → ARIA → text)已实现 - ✅ 错误处理和 JSDoc 注释模式已建立 **本故事需要做的:** - 复用 `findTrigger()` 函数查找下拉框触发器 - 实现 `waitForOptionAndSelect()` 函数处理异步等待 - 实现 `selectRadixOptionAsync()` 主函数 - 处理网络空闲等待(`waitForLoadState`) - 更新 `src/index.ts` 导出新函数 ### 前一个故事的关键经验 **从 Story 1.3 代码审查中学习到的经验:** 1. **DOM 类型问题解决:** 使用 `page.locator().allTextContents()` 替代 `page.evaluate()` 避免 TypeScript DOM 类型问题 2. **精确文本匹配:** 选项选择器使用 `:text-is()` 而非 `:has-text()` 避免部分匹配误选 3. **完整的 JSDoc 注释:** 内部函数需要完整的 JSDoc(@param, @returns, @throws) 4. **空 catch 块处理:** 添加 `console.debug` 改善调试体验 5. **等待策略优化:** 所有 `waitForSelector` 调用添加 `state: "visible"` 6. **错误上下文完整性:** 确保错误上下文包含所有必要参数 ### 异步 Select 与静态 Select 的区别 | 特性 | 静态 Select | 异步 Select | |------|------------|------------| | **选项加载时机** | 页面加载时已存在 | 点击触发器后 API 加载 | | **等待策略** | 立即查找选项 | 等待网络请求 + 选项出现 | | **默认超时** | 2000ms | 5000ms | | **错误类型** | 选项未找到 | 等待超时 | | **配置对象** | 无 | `AsyncSelectOptions` | ### DOM 结构理解 **异步 Select 的 DOM 结构与静态 Select 相同:** ```html
广东省
北京市
上海市
``` **关键区别:** - 选项不是一开始就存在于 DOM 中 - 需要等待 API 请求完成后选项才出现 - 可能需要处理加载状态显示 ### Playwright API 参考 **等待网络空闲:** ```typescript // 等待网络空闲(所有网络请求完成) await page.waitForLoadState('networkidle', { timeout: 5000 }); // 等待特定类型的加载状态 await page.waitForLoadState('domcontentloaded'); // DOM 加载完成 await page.waitForLoadState('load'); // 页面 load 事件 await page.waitForLoadState('networkidle'); // 网络空闲(至少 500ms 无网络活动) ``` **重试模式:** ```typescript // 使用重试机制等待异步元素 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 注释标准:** - 所有导出函数必须有完整 JSDoc - 使用 `@param`, `@throws`, `@example` 标签 - 添加实际使用示例代码 - 内部函数使用 `@internal` 标记 **命名约定:** - 函数:camelCase,动词+名词,异步函数添加 `Async` 后缀 - 内部函数:camelCase,动词开头(如 `waitForOptionAndSelect`) - 私有函数使用 `@internal` JSDoc 标记 ### 性能约束 **从 NFR 提取的性能要求:** - **NFR9**: 异步 Select 选择操作应在 5 秒内完成(默认超时) - **NFR10**: 工具函数本身的开销不超过 100ms(不包括 Playwright 操作时间) - **NFR13**: 异步选项提供可配置的超时参数,默认值为 5 秒 - **NFR14**: 工具函数使用 Playwright 的 auto-waiting 机制,减少显式等待的需要 **实现时需要考虑:** - 使用 `DEFAULT_TIMEOUTS.async` (5000ms) 作为默认超时 - 支持自定义超时配置 - 使用重试机制而非一次性 `waitForTimeout` - 优先使用 Playwright 的 `waitForSelector` 而不是 `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` 辅助函数) - ❌ 缺少 JSDoc 注释 - ❌ 使用无限 `while(true)` 循环(必须有超时限制) - ❌ 使用 `page.waitForTimeout(5000)` 作为主要等待策略(应该使用重试机制) ### 测试要求 **单元测试(在 Story 1.6 中实现):** 本故事创建的 `selectRadixOptionAsync` 函数将在 Story 1.6 中进行单元测试。 **当前验证方法:** - 类型检查:`pnpm typecheck` - JSDoc 验证:手动检查所有导出都有完整注释 - 手动测试:在真实 E2E 测试场景中验证(如残疾人管理的省份选择) ### Project Structure Notes **对齐项目 Monorepo 架构:** - 包位于 `packages/e2e-test-utils/` - 使用 workspace 协议安装:`@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 测试标准(核心标准文档) ### References **PRD 来源:** - [PRD - E2E测试工具包](_bmad-output/planning-artifacts/prd.md) - 项目需求概述 - [PRD - Radix UI Select 测试需求](_bmad-output/planning-artifacts/epics.md#radix-ui-select-测试支持-fr1-fr6) - FR1-FR6 需求 **Architecture 来源:** - [Architecture - API 设计模式](_bmad-output/planning-artifacts/architecture.md#api-design-pattern) - 4参数+配置对象 - [Architecture - 选择器策略](_bmad-output/planning-artifacts/architecture.md#selector-strategy) - 混合策略 - [Architecture - 错误处理策略](_bmad-output/planning-artifacts/architecture.md#error-handling-strategy) - 结构化错误类 - [Architecture - 性能约束](_bmad-output/planning-artifacts/architecture.md#performance-constraints) - NFR8-NFR14 - [Architecture - 实现模式](_bmad-output/planning-artifacts/architecture.md#implementation-patterns--consistency-rules) - 命名和格式约定 **标准文档来源:** - [E2E Radix UI 测试标准](docs/standards/e2e-radix-testing.md) - 核心测试标准文档 - [Project Context](_bmad-output/project-context.md) - 项目技术栈和规则 **Epic 来源:** - [Epic 1 - Story 1.4](_bmad-output/planning-artifacts/epics.md#story-14-实现异步-select-工具函数) - 原始用户故事和验收标准 **前一个故事:** - [Story 1.1 - 创建包基础结构和配置](_bmad-output/implementation-artifacts/1-1-create-package-structure.md) - 包基础设施 - [Story 1.2 - 实现类型定义和错误处理](_bmad-output/implementation-artifacts/1-2-implement-types-errors.md) - 类型、错误、常量 - [Story 1.3 - 实现静态 Select 工具函数](_bmad-output/implementation-artifacts/1-3-static-select-tool.md) - 静态 Select 函数和经验教训 ## Dev Agent Record ### Agent Model Used Claude (d8d-model) via create-story workflow ### Debug Log References ### Completion Notes List - 故事创建时间: 2026-01-08 - 基于 PRD、Architecture、E2E Radix 测试标准文档创建 - 基于 Story 1.3 的经验教训创建(代码审查发现的问题) - 包含完整的异步等待策略实现指南 - 包含与前一个故事的集成说明 - 区分异步和静态 Select 的关键差异 **实现建议:** - 复用 `findTrigger` 函数查找触发器 - 实现 `waitForOptionAndSelect` 函数处理异步等待 - 使用重试机制而非固定超时 - 超时时获取可用选项用于错误提示 - 遵循 Story 1.3 的代码审查经验(`allTextContents`、`:text-is()`、完整 JSDoc) **实现完成 (2026-01-09):** - ✅ 实现了 `selectRadixOptionAsync(page, label, value, options?)` 函数 - ✅ 复用了 `findTrigger()` 函数查找下拉框触发器 - ✅ 实现了 `waitForOptionAndSelect()` 内部函数处理异步等待 - ✅ 使用重试机制(100ms 间隔)等待选项出现 - ✅ 支持可配置超时(默认 5000ms) - ✅ 支持可选的网络空闲等待(`waitForNetworkIdle`) - ✅ 超时错误包含:超时时间、期望值、可用选项、修复建议 - ✅ 添加了完整的 JSDoc 注释(@description, @param, @throws, @example) - ✅ 类型检查通过(`pnpm typecheck`) **代码审查修复 (2026-01-09):** - ✅ 更新 JSDoc 添加 `waitForOption` 和 `waitForNetworkIdle` 参数说明 - ✅ 将 `waitForNetworkIdle` 默认值改为 `true`(符合 AC 要求) - ✅ 修复 `waitForLoadState` 超时配置,使用 `DEFAULT_TIMEOUTS.networkIdle` - ✅ `waitForOptionAndSelect` 添加文本匹配回退策略 - ✅ 添加注释说明网络空闲等待失败后继续执行的原因 - ✅ 更新 `types.ts` 中 `AsyncSelectOptions` JSDoc 补充完整说明 - ✅ 更新 `index.ts` 使用显式导出而非通配符 - ✅ 类型检查通过验证 ### File List **本故事修改的文件:** - `packages/e2e-test-utils/src/radix-select.ts` - 添加异步 Select 工具函数(修改) - `packages/e2e-test-utils/src/types.ts` - 更新 AsyncSelectOptions JSDoc(修改) - `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 测试标准