2
0

1-4-async-select-tool.md 17 KB

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

  • 实现 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 中定义

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 已完成的工作:

  • ✅ 静态 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 相同:

<!-- 触发器 -->
<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 参考

等待网络空闲:

// 等待网络空闲(所有网络请求完成)
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 注释标准:

  • 所有导出函数必须有完整 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 来源:

Architecture 来源:

标准文档来源:

Epic 来源:

前一个故事:

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 添加 waitForOptionwaitForNetworkIdle 参数说明
  • ✅ 将 waitForNetworkIdle 默认值改为 true(符合 AC 要求)
  • ✅ 修复 waitForLoadState 超时配置,使用 DEFAULT_TIMEOUTS.networkIdle
  • waitForOptionAndSelect 添加文本匹配回退策略
  • ✅ 添加注释说明网络空闲等待失败后继续执行的原因
  • ✅ 更新 types.tsAsyncSelectOptions 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 测试标准