2
0

1-3-static-select-tool.md 20 KB

Story 1.3: 实现静态 Select 工具函数

Status: done

Story

作为测试开发者, 我想要使用 selectRadixOption() 函数选择静态枚举型下拉框, 以便无需理解 Radix UI DOM 结构就能编写测试。

Acceptance Criteria

Given 类型定义和错误处理已创建(Story 1.1、1.2 完成) When 实现 src/radix-select.ts 中的 selectRadixOption() 函数 Then 函数签名:selectRadixOption(page: Page, label: string, value: string): Promise<void> And 选择器策略:data-testid → aria-label + role → text content And 自动处理点击触发器、等待选项列表、点击选项 And 错误时抛出 E2ETestError,包含标签、期望值、可用选项 And 操作在 2 秒内完成(NFR8) And 所有导出函数有完整的 JSDoc 注释

Tasks / Subtasks

  • 实现 src/radix-select.ts - 静态 Select 工具函数 (AC: 1, 2, 3, 4)
    • 实现 selectRadixOption(page, label, value) 主函数
    • 实现触发器查找逻辑(按优先级:testid → ARIA → text)
    • 实现选项查找逻辑(data-value → text content)
    • 实现点击触发器和选项的交互流程
    • 添加错误处理和友好错误消息
    • 为所有导出函数添加完整 JSDoc 注释
  • 更新 src/index.ts 导出新增函数 (AC: 6)
  • 类型检查通过验证 (AC: 7)
    • 运行 pnpm typecheck 确保无类型错误

Dev Notes

Epic 1 背景

Epic 1 目标: 测试开发者可以安装 @d8d/e2e-test-utils 包,立即使用 Select 工具测试 Radix UI Select 组件。

本故事在 Epic 中的位置: 第三个故事,实现核心的静态 Select 工具函数。这是第一个实际可用的工具函数,也是后续异步 Select 函数的基础。

架构约束和模式

从架构文档中必须遵循的决策:

选择器策略:混合策略(testid → ARIA → 文本)

  • 优先级 1: data-testid="${label}-trigger" - 最稳定,推荐
  • 优先级 2: aria-label="${label}" + role="combobox" - 无障碍属性
  • 优先级 3: text="${label}" - 文本匹配兜底

API 设计模式:3个必需参数 + 可选配置对象

  • 必需参数:page, label, value
  • 静态 Select 不需要配置对象(保持简洁)
  • 异步 Select 需要配置对象(在 Story 1.4 实现)

错误处理策略:结构化错误类 + 友好消息

  • 使用 E2ETestError 类(已在 Story 1.2 实现)
  • 包含 ErrorContext 信息(operation, target, expected, available, suggestion)
  • 错误消息格式:❌ 操作失败、上下文信息、💡 修复建议

技术实现要求

src/radix-select.ts - 静态 Select 工具函数实现指南:

import type { Page } from '@playwright/test';
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, '性别', '男');
 *
 * // 选择残疾等级
 * await selectRadixOption(page, '残疾等级', '一级');
 * ```
 */
export async function selectRadixOption(
  page: Page,
  label: string,
  value: string
): Promise<void> {
  // TODO: 实现触发器查找逻辑
  // TODO: 实现选项查找和点击逻辑
  // TODO: 添加错误处理
}

触发器查找逻辑实现:

/**
 * 查找 Radix UI Select 触发器
 *
 * @internal
 *
 * @param page - Playwright Page 对象
 * @param label - 下拉框标签
 * @returns 触发器元素
 * @throws {E2ETestError} 当触发器未找到时
 */
async function findTrigger(page: Page, label: string) {
  const timeout = DEFAULT_TIMEOUTS.static;

  // 策略 1: data-testid
  const testIdSelector = `[data-testid="${label}-trigger"]`;
  try {
    const trigger = await page.waitForSelector(testIdSelector, { timeout });
    if (trigger) return trigger;
  } catch {
    // 继续尝试下一个策略
  }

  // 策略 2: aria-label + role
  try {
    const ariaSelector = `[aria-label="${label}"][role="combobox"]`;
    const trigger = await page.waitForSelector(ariaSelector, { timeout });
    if (trigger) return trigger;
  } catch {
    // 继续尝试下一个策略
  }

  // 策略 3: text content
  try {
    const trigger = await page.waitForSelector(`text="${label}"`, { timeout });
    if (trigger) return trigger;
  } catch {
    // 所有策略都失败
  }

  // 未找到触发器
  throwError({
    operation: 'selectRadixOption',
    target: label,
    expected: value,
    suggestion: '检查下拉框标签是否正确,或添加 data-testid 属性'
  });
}

选项查找和点击逻辑实现:

/**
 * 查找并点击 Radix UI Select 选项
 *
 * @internal
 *
 * @param page - Playwright Page 对象
 * @param value - 选项值
 * @param availableOptions - 可用选项列表(用于错误提示)
 * @throws {E2ETestError} 当选项未找到时
 */
async function findAndClickOption(
  page: Page,
  value: string,
  availableOptions: string[]
) {
  const timeout = DEFAULT_TIMEOUTS.static;

  // 策略 1: data-value
  const dataValueSelector = `[role="option"][data-value="${value}"]`;
  try {
    const option = await page.waitForSelector(dataValueSelector, { timeout });
    if (option) {
      await option.click();
      return;
    }
  } catch {
    // 继续尝试下一个策略
  }

  // 策略 2: text content
  const textSelector = `[role="option"]:has-text("${value}")`;
  try {
    const option = await page.waitForSelector(textSelector, { timeout });
    if (option) {
      await option.click();
      return;
    }
  } catch {
    // 所有策略都失败
  }

  // 未找到选项
  throwError({
    operation: 'selectRadixOption',
    target: `选项 "${value}"`,
    available: availableOptions,
    suggestion: '检查选项值是否正确,或确认选项已加载到 DOM 中'
  });
}

主函数完整实现:

export async function selectRadixOption(
  page: Page,
  label: string,
  value: string
): Promise<void> {
  // 1. 查找触发器
  const trigger = await findTrigger(page, label);

  // 2. 点击触发器展开选项列表
  await trigger.click();

  // 3. 等待选项列表出现
  await page.waitForSelector('[role="listbox"]', {
    timeout: DEFAULT_TIMEOUTS.static
  });

  // 4. 获取可用选项列表(用于错误提示)
  const availableOptions = await page.evaluate(() => {
    const options = document.querySelectorAll('[role="option"]');
    return Array.from(options).map(opt => opt.textContent || '');
  });

  // 5. 查找并点击选项
  await findAndClickOption(page, value, availableOptions);

  // 6. 等待下拉框关闭(可选,确保操作完成)
  // 不需要显式等待,Playwright auto-waiting 会处理
}

src/index.ts - 更新主导出文件:

// ... 现有导出 ...

// Radix UI Select 工具
export { selectRadixOption } from './radix-select';

与前一个故事的集成

Story 1.1 已完成的工作:

  • ✅ 包结构已创建:packages/e2e-test-utils/
  • ✅ 配置文件已就绪:package.json, tsconfig.json, vitest.config.ts
  • ✅ 目录结构已创建:tests/unit/, tests/fixtures/

Story 1.2 已完成的工作:

  • ✅ 类型定义完整:src/types.ts 包含所有共享类型
  • ✅ 错误处理完整:src/errors.ts 包含 E2ETestErrorthrowError
  • ✅ 常量定义完整:src/constants.ts 包含 DEFAULT_TIMEOUTSSELECTOR_STRATEGIES

本故事需要做的:

  • 创建 src/radix-select.ts 实现静态 Select 工具函数
  • 更新 src/index.ts 导出新函数
  • 运行 pnpm typecheck 验证类型检查通过

DOM 结构理解

Radix UI Select 的典型 DOM 结构:

<!-- 触发器(点击展开下拉列表) -->
<button
  type="button"
  role="combobox"
  aria-label="残疾类型"
  data-radix-select-trigger=""
  data-state="closed"
>
  <span>残疾类型</span>
  <svg><!-- 下箭头图标 --></svg>
</button>

<!-- 选项列表容器(点击后展开) -->
<div role="listbox" data-radix-select-content="">
  <!-- 单个选项 -->
  <div
    role="option"
    data-value="blind"
    data-state="unchecked"
    aria-selected="false"
  >
    视力残疾
  </div>
  <div
    role="option"
    data-value="hearing"
    data-state="unchecked"
    aria-selected="false"
  >
    听力残疾
  </div>
  <div
    role="option"
    data-value="physical"
    data-state="unchecked"
    aria-selected="false"
  >
    肢体残疾
  </div>
  <div
    role="option"
    data-value="intellectual"
    data-state="unchecked"
    aria-selected="false"
  >
    智力残疾
  </div>
</div>

关键 DOM 属性:

  • 触发器:role="combobox", data-radix-select-trigger
  • 选项列表:role="listbox", data-radix-select-content
  • 单个选项:role="option", data-value(选项值)

Radix UI 选择器策略实现细节

从 E2E Radix 测试标准文档中提取的实现细节:

// 触发器选择器优先级
const TRIGGER_SELECTORS = [
  `[data-testid="${label}-trigger"]`,  // 最高优先级
  `text="${label}"`,                     // 文本匹配
  `[role="combobox"]`                    // 兜底
];

// 选项选择器优先级
const OPTION_SELECTORS = [
  `[role="option"][data-value="${value}"]`,  // data-value + role
  `[role="option"]:has-text("${value}")`      // 文本匹配兜底
];

推荐在应用代码中添加 data-testid:

<!-- 推荐方式:添加 data-testid -->
<button
  data-testid="残疾类型-trigger"
  role="combobox"
  aria-label="残疾类型"
>
  残疾类型
</button>

项目标准对齐

与项目标准对齐:

  • 遵循 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 标签
  • 添加实际使用示例代码

命名约定:

  • 函数:camelCase,动词+名词(如 selectRadixOption
  • 内部函数:camelCase,动词开头(如 findTrigger, findAndClickOption
  • 私有函数使用 @internal JSDoc 标记

性能约束

从 NFR 提取的性能要求:

  • NFR8: 静态 Select 选择操作应在 2 秒内完成(目标 < 1s)
  • NFR10: 工具函数本身的开销不超过 100ms(不包括 Playwright 操作时间)
  • NFR12: 静态选项使用合理的默认超时(2 秒),避免不必要的等待
  • NFR14: 工具函数使用 Playwright 的 auto-waiting 机制,减少显式等待的需要

实现时需要考虑:

  • 使用 DEFAULT_TIMEOUTS.static (2000ms) 而不是硬编码
  • 优先使用 Playwright 的 waitForSelector 而不是 waitForTimeout
  • 不添加额外的显式等待(让 auto-waiting 处理)

文件结构约束

必须遵循的文件结构:

packages/e2e-test-utils/src/
├── index.ts          # 主导出(需要更新)
├── types.ts          # 共享类型定义(已完成)
├── errors.ts         # 错误类(已完成)
├── constants.ts      # 常量定义(已完成)
└── radix-select.ts   # Radix UI Select 工具(本故事创建)

禁止事项(Anti-Patterns):

  • ❌ 使用 any 类型
  • ❌ 硬编码超时值(必须使用 DEFAULT_TIMEOUTS.static
  • ❌ 抛出原生 Error(必须使用 throwError 辅助函数)
  • ❌ 缺少 JSDoc 注释
  • ❌ 使用 waitForTimeout(应该依赖 auto-waiting)
  • ❌ 只实现单一选择器策略(必须按优先级尝试多种策略)

测试要求

单元测试(在 Story 1.6 中实现): 本故事创建的 selectRadixOption 函数将在 Story 1.6 中进行单元测试。

当前验证方法:

  • 类型检查:pnpm typecheck
  • JSDoc 验证:手动检查所有导出都有完整注释

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

Agent Model Used (Dev)\n\nClaude (d8d-model) via dev-story workflow

Agent Model Used (Dev)

Claude (d8d-model) via dev-story workflow

Debug Log References

Completion Notes List

  • 故事创建时间: 2026-01-08
  • 基于 PRD、Architecture、E2E Radix 测试标准文档创建
  • 包含完整的实现指南:触发器查找、选项查找、交互流程
  • 包含 DOM 结构理解和选择器策略详细说明
  • 为后续异步 Select 函数(Story 1.4)提供基础

实现完成 (2026-01-08):

  • ✅ 创建 src/radix-select.ts 实现静态 Select 工具函数
  • ✅ 实现主函数 selectRadixOption(page, label, value) - 完整的函数签名和实现
  • ✅ 实现触发器查找逻辑 - 按优先级尝试 testid → ARIA → text 三种策略
  • ✅ 实现选项查找逻辑 - 按优先级尝试 data-value → text content
  • ✅ 实现完整的交互流程 - 点击触发器 → 等待列表 → 获取选项 → 点击选项
  • ✅ 添加结构化错误处理 - 使用 throwErrorE2ETestError 提供友好错误信息
  • ✅ 为导出函数添加完整 JSDoc 注释 - 包含 @description, @param, @throws, @example
  • ✅ 更新 src/index.ts 导出新增函数
  • ✅ 类型检查通过 - 使用 page.locator().allTextContents() 替代 page.evaluate() 避免 DOM 类型问题
  • ✅ 使用 DEFAULT_TIMEOUTS.static 常量,符合架构要求
  • ✅ 遵循 Playwright auto-waiting 机制,无额外显式等待

技术决策:

  • 使用 page.locator("[role=option]").allTextContents() 而非 page.evaluate() 避免 TypeScript DOM 类型问题
  • 按优先级实现选择器回退策略,提高测试稳定性
  • 内部函数标记为 @internal,明确 API 边界

File List

本故事需要创建/修改的文件:

  • packages/e2e-test-utils/src/radix-select.ts - 静态 Select 工具函数(新建)
  • packages/e2e-test-utils/src/index.ts - 更新导出(修改)
  • packages/e2e-test-utils/tsconfig.json - 添加 DOM lib(修改)
  • CLAUDE.md - 项目配置文件(修改)

相关文件(已在 Story 1.1、1.2 中完成,本故事使用):

  • packages/e2e-test-utils/src/types.ts - 共享类型定义
  • packages/e2e-test-utils/src/errors.ts - 错误类和错误处理
  • packages/e2e-test-utils/src/constants.ts - 超时和选择器策略常量

只读参考文件:

  • _bmad-output/implementation-artifacts/1-1-create-package-structure.md - 前一个故事
  • _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 测试标准

Change Log

2026-01-08 - Story 1.3 代码审查修复

代码审查发现并修复了 8 个问题:

🔴 HIGH 修复 (2):

  1. findTrigger 错误上下文添加 expected 参数 - 修复错误消息不完整问题
  2. ✅ 文本选择器从 :has-text() 改为 :text-is() - 避免部分匹配误选

🟡 MEDIUM 修复 (6):

  1. ✅ 删除 findTrigger 中 3 处冗余的 null 检查 - 简化代码逻辑
  2. ✅ 删除 findAndClickOption 中冗余的 null 检查 - 减少嵌套层级
  3. ✅ 空 catch 块添加 console.debug - 改善调试体验
  4. ✅ 内部函数添加完整 JSDoc 注释(@param, @returns, @throws)- 符合项目标准
  5. ✅ 所有 waitForSelector 调用添加 state: "visible" - 更安全的等待策略
  6. ✅ File List 更新包含 CLAUDE.md - 完整文档修改记录

代码质量改进:

  • 删除冗余代码,提高可读性
  • 增强错误处理和调试信息
  • 完善文档注释
  • 精确文本匹配避免误选

2026-01-08 - Story 1.3 实现完成

  • ✅ 创建 src/radix-select.ts - 静态 Select 工具函数
  • ✅ 实现 selectRadixOption() 主函数及内部辅助函数
  • ✅ 实现完整的选择器回退策略(testid → ARIA → text)
  • ✅ 添加完整的 JSDoc 注释和错误处理
  • ✅ 更新 src/index.ts 导出新增函数
  • ✅ 类型检查通过
  • ✅ 状态更新为 review

2026-01-08 - Story 1.3 创建

  • ✅ 故事文档创建完成
  • ✅ 包含完整的实现指南
  • ✅ 包含 DOM 结构和选择器策略详细说明
  • ✅ 状态设置为 ready-for-dev

2026-01-08 - Story 1.3 实现

  • ✅ 创建 src/radix-select.ts 实现静态 Select 工具函数
  • ✅ 实现三级选择器策略(testid → ARIA → text)
  • ✅ 实现触发器和选项查找逻辑
  • ✅ 添加完整 JSDoc 注释
  • ✅ 更新 src/index.ts 导出新增函数
  • ✅ 更新 tsconfig.json 添加 DOM lib
  • ✅ 类型检查通过
  • ✅ 所有任务已完成,状态设置为 review