Преглед на файлове

feat(e2e-test-utils): 创建 Story 1.4 异步 Select 工具函数

- 实现 selectRadixOptionAsync() 函数处理异步加载选项
- 支持 AsyncSelectOptions 配置(timeout, waitForNetworkIdle)
- 使用重试机制等待异步选项出现
- 默认超时 5000ms,可自定义配置
- 整合 Story 1.3 代码审查经验教训
- 更新 sprint-status.yaml 状态为 ready-for-dev

🤖 Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname преди 1 седмица
родител
ревизия
88f5d335fd
променени са 2 файла, в които са добавени 459 реда и са изтрити 1 реда
  1. 458 0
      _bmad-output/implementation-artifacts/1-4-async-select-tool.md
  2. 1 1
      _bmad-output/implementation-artifacts/sprint-status.yaml

+ 458 - 0
_bmad-output/implementation-artifacts/1-4-async-select-tool.md

@@ -0,0 +1,458 @@
+# Story 1.4: 实现异步 Select 工具函数
+
+Status: ready-for-dev
+
+<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
+
+## 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
+
+- [ ] 实现 `src/radix-select.ts` - 异步 Select 工具函数 (AC: 1, 2, 3, 4, 5)
+  - [ ] 实现 `selectRadixOptionAsync(page, label, value, options?)` 主函数
+  - [ ] 复用 `findTrigger` 逻辑查找下拉框触发器
+  - [ ] 实现异步选项等待逻辑(网络空闲 + 选项可见)
+  - [ ] 实现可配置超时机制(默认 5000ms)
+  - [ ] 添加超时错误处理(包含超时时间、可能原因)
+  - [ ] 为所有导出函数添加完整 JSDoc 注释
+- [ ] 更新 `src/index.ts` 导出新增函数 (AC: 6)
+- [ ] 类型检查通过验证
+  - [ ] 运行 `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<void> {
+  // TODO: 实现异步选项选择逻辑
+}
+```
+
+**异步等待策略实现:**
+
+```typescript
+/**
+ * 等待异步选项加载并完成选择
+ *
+ * @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: '检查网络请求是否正常,或增加超时时间'
+  });
+}
+```
+
+**主函数完整实现:**
+
+```typescript
+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 已完成的工作:**
+- ✅ 静态 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
+<!-- 触发器 -->
+<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>
+```
+
+**关键区别:**
+- 选项不是一开始就存在于 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)
+
+### File List
+
+**本故事需要创建/修改的文件:**
+- `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 测试标准
+

+ 1 - 1
_bmad-output/implementation-artifacts/sprint-status.yaml

@@ -45,7 +45,7 @@ development_status:
   1-1-create-package-structure: done
   1-2-implement-types-errors: done
   1-3-static-select-tool: done
-  1-4-async-select-tool: backlog
+  1-4-async-select-tool: ready-for-dev
   1-5-main-export-docs: backlog
   1-6-select-unit-tests: backlog
   epic-1-retrospective: optional