Status: done
作为测试开发者,
我想要使用 selectRadixOption() 替换 Page Object 中的 Select 操作,
以便验证工具在静态 Select 场景中的可用性。
web/tests/e2e/pages/admin/disability-person.page.tsfillBasicForm() 中的残疾类型选择使用 selectRadixOption()fillBasicForm() 中的残疾等级选择使用 selectRadixOption()addBankCard() 中的银行卡名称选择使用 selectRadixOption()addBankCard() 中的银行卡类型选择使用 selectRadixOption()(如适用)addVisit() 中的回访类型选择使用 selectRadixOption()selectRadixOption() 方法用于省份/城市异步选择(添加 TODO 注释说明将在 Story 2.3 中移除)import { selectRadixOption } from '@d8d/e2e-test-utils'await selectRadixOption(this.page, '残疾类型 *', data.disabilityType)await selectRadixOption(this.page, '残疾等级 *', data.disabilityLevel)await selectRadixOption(this.page, '银行名称', bankCard.bankName)await selectRadixOption(this.page, '银行卡类型', bankCard.cardType)await selectRadixOption(this.page, '回访类型', visit.visitType)pnpm test:e2e:chromium disability-person-complete.spec.tsEpic 2 目标: 在 web/tests/e2e/ 的现有残疾人管理测试中使用 Select 工具,验证工具在真实场景中的可用性和稳定性。
Epic 2 范围:
web/tests/e2e/ 测试基础设施静态 Select 场景(本故事):
异步 Select 场景(Story 2.3):
在文件顶部添加导入:
// web/tests/e2e/pages/admin/disability-person.page.ts
import { Page, Locator } from '@playwright/test';
import { selectRadixOption } from '@d8d/e2e-test-utils'; // 新增
原实现(自定义方法):
// this.selectRadixOption 是类方法
await this.selectRadixOption('残疾类型 *', data.disabilityType);
新实现(工具函数):
// selectRadixOption 是导入的工具函数,需要传入 page 对象
await selectRadixOption(this.page, '残疾类型 *', data.disabilityType);
关键差异:
page 对象作为第一个参数selectRadixOption(page: Page, label: string, value: string): Promise<void>| 方法 | 行号 | Select 字段 | 标签文本 |
|---|---|---|---|
fillBasicForm |
85 | 残疾类型 | 残疾类型 * |
fillBasicForm |
86 | 残疾等级 | 残疾等级 * |
addBankCard |
239 | 银行名称 | 银行名称 |
addBankCard |
246 | 银行卡类型 | 银行卡类型 |
addVisit |
310 | 回访类型 | 回访类型 |
技术决策: 保留第 97-105 行的自定义 selectRadixOption 方法,用于省份/城市异步选择场景。
原因:
selectRadixOptionAsync实现:
this.selectRadixOption当前代码(第 97-105 行):
// TODO: 此方法将在 Story 2.3 中移除,届时省份/城市将使用 selectRadixOptionAsync
// 保留此方法用于支持省份/城市的异步 Select 选择
async selectRadixOption(label: string, value: string) {
const combobox = this.page.getByRole('combobox', { name: label });
await combobox.click({ timeout: 2000 });
const option = this.page.getByRole('option', { name: value }).first();
await option.click({ timeout: 3000 });
console.log(` ✓ ${label} 选中: ${value}`);
}
工具函数内部没有 console.log,如果需要保留调试输出,可以在调用后添加:
await selectRadixOption(this.page, '残疾类型 *', data.disabilityType);
console.debug(` ✓ 残疾类型选中: ${data.disabilityType}`);
前置依赖:
后续故事:
代码修改完成后:
# 类型检查
pnpm typecheck
运行测试:
# 运行残疾人管理完整流程测试
pnpm test:e2e:chromium disability-person-complete.spec.ts
观察点:
如果工具函数抛出 E2ETestError:
触发器未找到:
Error: Radix Select 触发器未找到
标签: 残疾类型 *
期望值: 视力残疾
建议: 检查下拉框标签是否正确,或添加 data-testid 属性
选项未找到:
Error: Radix Select 选项 "xxx" 未找到
标签: 残疾类型
期望值: xxx
可用选项: 视力残疾, 听力残疾, 肢体残疾, ...
建议: 检查选项值是否正确,或确认选项已加载到 DOM 中
文件路径:
web/tests/e2e/pages/admin/disability-person.page.ts
相关测试文件:
web/tests/e2e/specs/admin/disability-person-complete.spec.ts
工具包位置:
packages/e2e-test-utils/
Epic 2 详情: _bmad-output/planning-artifacts/epics.md#Epic-2
Story 2.1(安装): _bmad-output/implementation-artifacts/2-1-install-e2e-utils.md
Epic 1 回顾(技术经验): _bmad-output/implementation-artifacts/epic-1-retrospective.md
工具包源码: packages/e2e-test-utils/src/radix-select.ts
项目上下文: _bmad-output/project-context.md
重要提示: 本部分包含开发者实现此故事所需的所有关键上下文和约束条件。
selectRadixOption 函数来自 @d8d/e2e-test-utils 包:
import { selectRadixOption } from '@d8d/e2e-test-utils';
// 函数签名
selectRadixOption(page: Page, label: string, value: string): Promise<void>
参数说明:
page: Playwright Page 对象label: 下拉框的标签文本(用于定位触发器)value: 要选择的选项值返回值: Promise
异常: E2ETestError - 当触发器或选项未找到时
工具函数按以下优先级查找触发器:
[data-testid="${label}-trigger"][aria-label="${label}"][role="combobox"]text="${label}"选项选择策略:
[role="option"][data-value="${value}"][role="option"]:text-is("${value}")文件位置: web/tests/e2e/pages/admin/disability-person.page.ts
现有代码(需要修改的部分):
// 第 1-2 行:导入部分
import { Page, Locator } from '@playwright/test';
// 需要添加:import { selectRadixOption } from '@d8d/e2e-test-utils';
// 第 64-94 行:fillBasicForm 方法
async fillBasicForm(data: {
// ... 数据字段
}) {
await this.page.waitForSelector('form#create-form', { state: 'visible', timeout: 5000 });
await this.page.getByLabel('姓名 *').fill(data.name);
await this.page.getByLabel('性别 *').selectOption(data.gender); // 原生 select,不需要改
await this.page.getByLabel('身份证号 *').fill(data.idCard);
await this.page.getByLabel('残疾证号 *').fill(data.disabilityId);
await this.selectRadixOption('残疾类型 *', data.disabilityType); // ❌ 需要替换
await this.selectRadixOption('残疾等级 *', data.disabilityLevel); // ❌ 需要替换
// ... 其他代码
await this.selectRadixOption('省份 *', data.province); // Story 2.3 处理
await this.page.waitForTimeout(500); // Story 2.3 会移除这个 hack
await this.selectRadixOption('城市', data.city); // Story 2.3 处理
}
// 第 97-105 行:自定义方法(保留用于异步选择,将在 Story 2.3 中移除)
// TODO: 此方法将在 Story 2.3 中移除,届时省份/城市将使用 selectRadixOptionAsync
// 保留此方法用于支持省份/城市的异步 Select 选择
async selectRadixOption(label: string, value: string) {
const combobox = this.page.getByRole('combobox', { name: label });
await combobox.click({ timeout: 2000 });
const option = this.page.getByRole('option', { name: value }).first();
await option.click({ timeout: 3000 });
console.log(` ✓ ${label} 选中: ${value}`);
}
// 第 225-261 行:addBankCard 方法
async addBankCard(bankCard: {
bankName: string;
subBankName: string;
cardNumber: string;
cardholderName: string;
cardType?: string;
photoFileName?: string;
}) {
await this.page.getByRole('button', { name: /添加银行卡/ }).click();
await this.page.waitForTimeout(300);
await this.selectRadixOption('银行名称', bankCard.bankName); // ❌ 需要替换
await this.page.getByLabel(/发卡支行/).fill(bankCard.subBankName);
await this.page.getByLabel(/银行卡号/).fill(bankCard.cardNumber);
await this.page.getByLabel(/持卡人姓名/).fill(bankCard.cardholderName);
if (bankCard.cardType) {
await this.selectRadixOption('银行卡类型', bankCard.cardType); // ❌ 需要替换
}
// ... 其他代码
}
// 第 296-332 行:addVisit 方法
async addVisit(visit: {
visitDate: string;
visitType: string;
visitContent: string;
visitResult?: string;
nextVisitDate?: string;
}) {
await this.page.getByRole('button', { name: /添加回访/ }).click();
await this.page.waitForTimeout(300);
await this.page.getByLabel(/回访日期/).fill(visit.visitDate);
await this.selectRadixOption('回访类型', visit.visitType); // ❌ 需要替换
// ... 其他代码
}
| 步骤 | 操作 | 位置 | 详情 |
|---|---|---|---|
| 1 | 添加导入 | 第 2 行后 | import { selectRadixOption } from '@d8d/e2e-test-utils'; |
| 2 | 替换调用 | 第 86 行 | await selectRadixOption(this.page, '残疾类型 *', data.disabilityType) |
| 3 | 替换调用 | 第 87 行 | await selectRadixOption(this.page, '残疾等级 *', data.disabilityLevel) |
| 4 | 替换调用 | 第 236 行 | await selectRadixOption(this.page, '银行名称', bankCard.bankName) |
| 5 | 替换调用 | 第 243 行 | await selectRadixOption(this.page, '银行卡类型', bankCard.cardType) |
| 6 | 替换调用 | 第 307 行 | await selectRadixOption(this.page, '回访类型', visit.visitType) |
| 7 | 添加 TODO | 第 97 行 | 在自定义方法上方添加 TODO 注释 |
修改前:
async fillBasicForm(data: { /* ... */ }) {
// ...
await this.selectRadixOption('残疾类型 *', data.disabilityType);
await this.selectRadixOption('残疾等级 *', data.disabilityLevel);
// ...
await this.selectRadixOption('省份 *', data.province); // 仍使用自定义方法
await this.page.waitForTimeout(500);
await this.selectRadixOption('城市', data.city); // 仍使用自定义方法
}
async selectRadixOption(label: string, value: string) {
const combobox = this.page.getByRole('combobox', { name: label });
await combobox.click({ timeout: 2000 });
const option = this.page.getByRole('option', { name: value }).first();
await option.click({ timeout: 3000 });
console.log(` ✓ ${label} 选中: ${value}`);
}
修改后:
import { selectRadixOption } from '@d8d/e2e-test-utils'; // 新增
async fillBasicForm(data: { /* ... */ }) {
// ...
await selectRadixOption(this.page, '残疾类型 *', data.disabilityType); // 使用工具函数
await selectRadixOption(this.page, '残疾等级 *', data.disabilityLevel); // 使用工具函数
// ...
await this.selectRadixOption('省份 *', data.province); // 保留自定义方法(异步场景)
await this.page.waitForTimeout(500);
await this.selectRadixOption('城市', data.city); // 保留自定义方法(异步场景)
}
// TODO: 此方法将在 Story 2.3 中移除,届时省份/城市将使用 selectRadixOptionAsync
// 保留此方法用于支持省份/城市的异步 Select 选择
async selectRadixOption(label: string, value: string) {
const combobox = this.page.getByRole('combobox', { name: label });
await combobox.click({ timeout: 2000 });
const option = this.page.getByRole('option', { name: value }).first();
await option.click({ timeout: 3000 });
console.log(` ✓ ${label} 选中: ${value}`);
}
单一职责:
selectRadixOption 专注于静态 Select 选择selectRadixOptionAsync)错误处理:
E2ETestError 提供结构化错误信息超时配置:
DEFAULT_TIMEOUTS.static)工具包类型定义:
// packages/e2e-test-utils/src/radix-select.ts
export async function selectRadixOption(
page: Page,
label: string,
value: string
): Promise<void>
使用时类型检查:
E2ETestError| 包 | 版本 | 要求 |
|---|---|---|
| @playwright/test (web) | 1.55.0 | ✅ 满足 ^1.40.0 |
| @d8d/e2e-test-utils | - | peer dependency: ^1.40.0 |
导出的函数:
// packages/e2e-test-utils/src/index.ts
export { selectRadixOption } from './radix-select.js';
export { selectRadixOptionAsync } from './radix-select.js';
// 类型
export type { BaseOptions } from './types.js';
export type { AsyncSelectOptions } from './radix-select.js';
// 错误
export { E2ETestError } from './errors.js';
export type { ErrorContext } from './errors.js';
主要修改:
web/tests/e2e/pages/admin/disability-person.page.ts不影响其他文件:
web/tests/e2e/specs/admin/disability-person-complete.spec.ts(测试文件不需要修改)web/package.json(依赖已在 Story 2.1 添加)packages/e2e-test-utils/
├── src/
│ ├── radix-select.ts # selectRadixOption 实现
│ ├── types.ts # 类型定义
│ ├── errors.ts # 错误处理
│ ├── constants.ts # 常量(超时配置)
│ └── index.ts # 主导出
├── package.json
└── README.md
1. 代码修改完成后:
# 类型检查
pnpm typecheck
2. 运行测试:
# 运行残疾人管理完整流程测试
pnpm test:e2e:chromium disability-person-complete.spec.ts
3. 观察点:
E2ETestError 错误测试场景:
// 测试代码会调用 fillBasicForm
await page.fillBasicForm({
name: '测试用户',
gender: '男',
idCard: '110101199001011234',
disabilityId: '1101011990',
disabilityType: '视力残疾', // 使用 selectRadixOption
disabilityLevel: '一级', // 使用 selectRadixOption
phone: '13800138000',
idAddress: '北京市东城区',
province: '广东省', // Story 2.3 处理
city: '深圳市', // Story 2.3 处理
});
预期结果:
1. TypeScript + Playwright 陷阱 [来源: epic-1-retrospective.md]
page.evaluate() 获取文本page.locator().allTextContents()2. 精确文本匹配 [来源: epic-1-retrospective.md]
:has-text() 会部分匹配,可能误选:text-is() 进行精确匹配3. 代码审查发现的问题类型:
4. 测试覆盖率目标:
选择器策略优先级:
data-testid - 最高优先级(推荐在 Radix 组件上添加)aria-label + role - 无障碍标准错误处理模式:
E2ETestError 而非原生 Error最近 5 次提交:
02ece3b fix(e2e-test-utils): 完成 Story 2.1 代码审查修复
35bde40 docs(e2e-test-utils): 创建 Story 2.1 安装 e2e-utils 包
54bded5 docs: 完成 Epic 1 回顾及技术改进
f72ea78 test(e2e-test-utils): 完成 Story 1.6 Select 工具单元测试及代码审查
5350962 ✨ feat(rpc-client): 添加辅助函数以不区分大小写获取响应头
相关文件修改历史:
web/tests/e2e/pages/admin/disability-person.page.ts - 在 Story 2.1 中添加了注释说明工具包已安装代码模式:
完整项目上下文: _bmad-output/project-context.md
关键规范:
any 类型pnpm test:e2e:chromium相关文档:
_bmad-output/planning-artifacts/epics.md#Epic-2_bmad-output/implementation-artifacts/2-1-install-e2e-utils.md_bmad-output/implementation-artifacts/epic-1-retrospective.md完成本故事后,继续:
Claude Opus 4.5 (claude-opus-4-5-20251101)
(开发过程中添加调试日志引用)
实现完成时间: 2026-01-09
实现概述:
selectRadixOption 工具函数fillBasicForm: 残疾类型、残疾等级addBankCard: 银行名称、银行卡类型addVisit: 回访类型selectRadixOption 方法用于省份/城市异步选择(添加 TODO 注释)技术决策:
selectRadixOptionAsync验证结果:
修改的文件:
web/tests/e2e/pages/admin/disability-person.page.ts修改详情:
import { selectRadixOption } from '@d8d/e2e-test-utils';创建时间: 2026-01-09
创建内容:
实现完成时间: 2026-01-09
实现内容:
@d8d/e2e-test-utils 的 selectRadixOption 替换 5 处静态 Select 调用代码审查更新 (2026-01-09):