Status: ready-for-dev
作为测试开发者,
我想要使用 selectRadixOption() 函数选择静态枚举型下拉框,
以便无需理解 Radix UI DOM 结构就能编写测试。
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 注释
src/radix-select.ts - 静态 Select 工具函数 (AC: 1, 2, 3, 4)
selectRadixOption(page, label, value) 主函数src/index.ts 导出新增函数 (AC: 6)pnpm typecheck 确保无类型错误Epic 1 目标: 测试开发者可以安装 @d8d/e2e-test-utils 包,立即使用 Select 工具测试 Radix UI Select 组件。
本故事在 Epic 中的位置: 第三个故事,实现核心的静态 Select 工具函数。这是第一个实际可用的工具函数,也是后续异步 Select 函数的基础。
从架构文档中必须遵循的决策:
选择器策略:混合策略(testid → ARIA → 文本)
data-testid="${label}-trigger" - 最稳定,推荐aria-label="${label}" + role="combobox" - 无障碍属性text="${label}" - 文本匹配兜底API 设计模式:3个必需参数 + 可选配置对象
page, label, value错误处理策略:结构化错误类 + 友好消息
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.tstests/unit/, tests/fixtures/Story 1.2 已完成的工作:
src/types.ts 包含所有共享类型src/errors.ts 包含 E2ETestError 和 throwErrorsrc/constants.ts 包含 DEFAULT_TIMEOUTS 和 SELECTOR_STRATEGIES本故事需要做的:
src/radix-select.ts 实现静态 Select 工具函数src/index.ts 导出新函数pnpm typecheck 验证类型检查通过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-triggerrole="listbox", data-radix-select-contentrole="option", data-value(选项值)从 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 注释标准:
@param, @throws, @example 标签命名约定:
selectRadixOption)findTrigger, findAndClickOption)@internal JSDoc 标记从 NFR 提取的性能要求:
实现时需要考虑:
DEFAULT_TIMEOUTS.static (2000ms) 而不是硬编码waitForSelector 而不是 waitForTimeout必须遵循的文件结构:
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 辅助函数)waitForTimeout(应该依赖 auto-waiting)单元测试(在 Story 1.6 中实现):
本故事创建的 selectRadixOption 函数将在 Story 1.6 中进行单元测试。
当前验证方法:
pnpm typecheck对齐项目 Monorepo 架构:
packages/e2e-test-utils/@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 测试标准(核心标准文档)PRD 来源:
Architecture 来源:
标准文档来源:
Epic 来源:
前一个故事:
Claude (d8d-model) via create-story workflow
本故事需要创建/修改的文件:
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 测试标准