ソースを参照

feat(e2e-test-utils): 创建 Story 1.3 静态 Select 工具函数

- 创建完整的 story 文档,包含实现指南
- 定义 selectRadixOption() 函数签名和实现细节
- 包含 DOM 结构理解和选择器策略说明
- 更新 sprint-status.yaml 状态为 ready-for-dev
- 更新 CLAUDE.md 添加长文档分段生成指南

🤖 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 週間 前
コミット
46197dec2e

+ 5 - 1
CLAUDE.md

@@ -24,4 +24,8 @@
   - **Jest**: 只支持 `--testNamePattern`,mini是Jest
   - **Jest**: 只支持 `--testNamePattern`,mini是Jest
   - **Mini测试**: 需要先进入mini目录再运行 `pnpm test --testNamePattern "测试名称"`
   - **Mini测试**: 需要先进入mini目录再运行 `pnpm test --testNamePattern "测试名称"`
 - **表单调试**: 表单提交失败时,在表单form onsubmit=form.handleSubmit的第二个参数中加console.debug来看表单验证错误,例如:`form.handleSubmit(handleSubmit, (errors) => console.debug('表单验证错误:', errors))`
 - **表单调试**: 表单提交失败时,在表单form onsubmit=form.handleSubmit的第二个参数中加console.debug来看表单验证错误,例如:`form.handleSubmit(handleSubmit, (errors) => console.debug('表单验证错误:', errors))`
-- 类型检查 可以用 pnpm typecheck 加 grep来过滤要检查的 指定文件
+- 类型检查 可以用 pnpm typecheck 加 grep来过滤要检查的 指定文件
+- **长文档分段生成**: 如果文档(如 story 文档)太长一次生成不完,应该分段生成:
+  1. 先使用 Write 工具创建文件的基础部分(header、requirements)
+  2. 使用 Edit 工具逐步添加其他部分(dev notes、references、dev agent record 等)
+  3. 每次添加一个主要部分,确保不会超过单次生成的长度限制

+ 509 - 0
_bmad-output/implementation-artifacts/1-3-static-select-tool.md

@@ -0,0 +1,509 @@
+# Story 1.3: 实现静态 Select 工具函数
+
+Status: ready-for-dev
+
+<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
+
+## 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 工具函数实现指南:**
+
+```typescript
+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: 添加错误处理
+}
+```
+
+**触发器查找逻辑实现:**
+
+```typescript
+/**
+ * 查找 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 属性'
+  });
+}
+```
+
+**选项查找和点击逻辑实现:**
+
+```typescript
+/**
+ * 查找并点击 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 中'
+  });
+}
+```
+
+**主函数完整实现:**
+
+```typescript
+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` - 更新主导出文件:**
+
+```typescript
+// ... 现有导出 ...
+
+// 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` 包含 `E2ETestError` 和 `throwError`
+- ✅ 常量定义完整:`src/constants.ts` 包含 `DEFAULT_TIMEOUTS` 和 `SELECTOR_STRATEGIES`
+
+**本故事需要做的:**
+- 创建 `src/radix-select.ts` 实现静态 Select 工具函数
+- 更新 `src/index.ts` 导出新函数
+- 运行 `pnpm typecheck` 验证类型检查通过
+
+### DOM 结构理解
+
+**Radix UI Select 的典型 DOM 结构:**
+
+```html
+<!-- 触发器(点击展开下拉列表) -->
+<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 测试标准文档中提取的实现细节:**
+
+```typescript
+// 触发器选择器优先级
+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:**
+```html
+<!-- 推荐方式:添加 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 来源:**
+- [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 - 包结构策略](_bmad-output/planning-artifacts/architecture.md#package-structure) - 按功能分组
+- [Architecture - API 设计模式](_bmad-output/planning-artifacts/architecture.md#api-design-pattern) - 3参数+配置对象
+- [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#implementation-patterns--consistency-rules) - 命名和格式约定
+- [Architecture - 项目结构](_bmad-output/planning-artifacts/architecture.md#project-structure--boundaries) - 完整目录结构
+
+**标准文档来源:**
+- [E2E Radix UI 测试标准](docs/standards/e2e-radix-testing.md) - 核心测试标准文档,包含 DOM 结构和选择器策略
+- [Project Context](_bmad-output/project-context.md) - 项目技术栈和规则
+
+**Epic 来源:**
+- [Epic 1 - Story 1.3](_bmad-output/planning-artifacts/epics.md#story-13-实现静态-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) - 类型、错误、常量
+
+## 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 测试标准文档创建
+- 包含完整的实现指南:触发器查找、选项查找、交互流程
+- 包含 DOM 结构理解和选择器策略详细说明
+- 为后续异步 Select 函数(Story 1.4)提供基础
+
+### File List
+
+**本故事需要创建/修改的文件:**
+- `packages/e2e-test-utils/src/radix-select.ts` - 静态 Select 工具函数(新建)
+- `packages/e2e-test-utils/src/index.ts` - 更新导出(修改)
+
+**相关文件(已在 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 创建
+- ✅ 故事文档创建完成
+- ✅ 包含完整的实现指南
+- ✅ 包含 DOM 结构和选择器策略详细说明
+- ✅ 状态设置为 ready-for-dev

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

@@ -44,7 +44,7 @@ development_status:
   epic-1: in-progress
   epic-1: in-progress
   1-1-create-package-structure: done
   1-1-create-package-structure: done
   1-2-implement-types-errors: done
   1-2-implement-types-errors: done
-  1-3-static-select-tool: backlog
+  1-3-static-select-tool: in-progress
   1-4-async-select-tool: backlog
   1-4-async-select-tool: backlog
   1-5-main-export-docs: backlog
   1-5-main-export-docs: backlog
   1-6-select-unit-tests: backlog
   1-6-select-unit-tests: backlog