Status: done
作为测试开发者,
我想要使用 selectRadixOptionAsync() 处理异步加载的 Select,
以便验证工具在异步 Select 场景中的可用性。
web/tests/e2e/pages/admin/disability-person.page.tsfillBasicForm() 中的省份选择使用 selectRadixOptionAsync()fillBasicForm() 中的城市选择使用 selectRadixOptionAsync()waitForTimeout(500) 等待城市加载的 hackselectRadixOption() 方法(第 97-105 行)import { selectRadixOption, selectRadixOptionAsync } from '@d8d/e2e-test-utils'await selectRadixOptionAsync(this.page, '省份 *', data.province)await selectRadixOptionAsync(this.page, '城市', data.city)await this.page.waitForTimeout(500) 行async-select-test.spec.ts 的测试基础设施问题(添加登录步骤)以下问题在代码审查中发现,需要后续处理:
radix-select.test.ts 到文件列表 [packages/e2e-test-utils/tests/unit/radix-select.test.ts]selectRadixOption 中的调试日志 [radix-select.ts:32-42]async-select-test.spec.ts 的实际测试运行验证 [async-select-test.spec.ts]selectRadixOptionAsync 应与 selectRadixOption 保持一致的日志策略 [radix-select.ts:206-249]DEFAULT_TIMEOUTS.static [radix-select.ts:105]async-select-test.spec.ts 的实际测试运行验证 [async-select-test.spec.ts]注意: 选择器策略冗余问题(M5)暂不处理,因为 4 个策略可以覆盖更多边缘情况,当前实现已通过单元测试验证。
review 改为 in-progress,修复完成后应改为 reviewDEFAULT_TIMEOUTS.static [radix-select.ts:105]async-select-test.spec.ts 所有测试场景通过代码审查修复完成 (2026-01-09):
radix-select.test.ts 到文件列表selectRadixOption 中的调试日志已改为 console.debugselectRadixOptionAsync 已添加与 selectRadixOption 一致的日志策略DEFAULT_TIMEOUTS.staticE2E 测试结果 (2026-01-09):
selectRadixOptionAsync 工具函数工作正常getByRole) 成功找到触发器console.debug 日志正确输出Epic 2 目标: 在 web/tests/e2e/ 的现有残疾人管理测试中使用 Select 工具,验证工具在真实场景中的可用性和稳定性。
Epic 2 范围:
web/tests/e2e/ 测试基础设施异步 Select 场景(本故事):
配置要点:
waitForOption: true 等待选项加载waitForNetworkIdle: true 确保数据加载完成DEFAULT_TIMEOUTS.async)在文件顶部添加导入(需要同时保留静态和异步函数):
// web/tests/e2e/pages/admin/disability-person.page.ts
import { Page, Locator } from '@playwright/test';
import { selectRadixOption, selectRadixOptionAsync } from '@d8d/e2e-test-utils';
注意: 仍然需要保留 selectRadixOption 导入,因为其他方法(addBankCard, addVisit)仍然使用它处理静态 Select。
原实现(自定义方法 + 等待 hack):
// this.selectRadixOption 是类方法
await this.selectRadixOption('省份 *', data.province);
await this.page.waitForTimeout(500); // hack: 等待城市加载
await this.selectRadixOption('城市', data.city);
新实现(工具函数 + 自动等待):
// selectRadixOptionAsync 是导入的工具函数,需要传入 page 对象
await selectRadixOptionAsync(this.page, '省份 *', data.province);
await selectRadixOptionAsync(this.page, '城市', data.city);
关键差异:
page 对象作为第一个参数selectRadixOptionAsync(page: Page, label: string, value: string, options?: AsyncSelectOptions): Promise<void>waitForTimeout(500) hack,工具函数自动处理异步加载| 方法 | 行号 | Select 字段 | 标签文本 | 当前实现 |
|---|---|---|---|---|
fillBasicForm |
92 | 省份 | 省份 * |
this.selectRadixOption |
fillBasicForm |
93 | (等待 hack) | - | waitForTimeout(500) |
fillBasicForm |
94 | 城市 | 城市 |
this.selectRadixOption |
技术决策: 完全移除第 97-105 行的自定义 selectRadixOption 方法。
原因:
selectRadixOption 工具函数selectRadixOptionAsync 工具函数移除的代码:
// 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}`);
}
selectRadixOptionAsync 内置的等待策略:
// 1. 查找触发器并点击
const trigger = await findTrigger(page, label, value);
await trigger.click();
// 2. 等待选项列表容器出现
await page.waitForSelector('[role="listbox"]', {
timeout: DEFAULT_TIMEOUTS.static,
state: 'visible'
});
// 3. 等待网络空闲(处理大量数据加载)
if (config.waitForNetworkIdle) {
try {
await page.waitForLoadState('networkidle', { timeout: config.timeout });
} catch (err) {
console.debug('网络空闲等待超时,继续尝试选择选项', err);
}
}
// 4. 等待选项出现并选择(重试机制)
await waitForOptionAndSelect(page, value, config.timeout);
优势:
waitForTimeout文件路径:
web/tests/e2e/pages/admin/disability-person.page.ts
相关测试文件:
web/tests/e2e/specs/admin/disability-person-complete.spec.ts
工具包位置:
packages/e2e-test-utils/src/radix-select.ts
Epic 2 详情: _bmad-output/planning-artifacts/epics.md#Epic-2
Story 2.1(安装): _bmad-output/implementation-artifacts/2-1-install-e2e-utils.md
Story 2.2(静态 Select): _bmad-output/implementation-artifacts/2-2-rewrite-static-select.md
Epic 1 回顾(技术经验): _bmad-output/implementation-artifacts/epic-1-retrospective.md
工具包源码: packages/e2e-test-utils/src/radix-select.ts
测试标准: docs/standards/e2e-radix-testing.md
项目上下文: _bmad-output/project-context.md
重要提示: 本部分包含开发者实现此故事所需的所有关键上下文和约束条件。
selectRadixOptionAsync 函数来自 @d8d/e2e-test-utils 包:
import { selectRadixOptionAsync } from '@d8d/e2e-test-utils';
// 函数签名
selectRadixOptionAsync(
page: Page,
label: string,
value: string,
options?: AsyncSelectOptions
): Promise<void>
// AsyncSelectOptions 接口
interface AsyncSelectOptions {
timeout?: number; // 超时时间(毫秒),默认 5000
waitForOption?: boolean; // 是否等待选项加载完成,默认 true
waitForNetworkIdle?: boolean; // 是否等待网络空闲后再操作,默认 true
}
参数说明:
page: Playwright Page 对象label: 下拉框的标签文本(用于定位触发器)value: 要选择的选项值options: 可选配置对象返回值: Promise
异常: E2ETestError - 当触发器未找到或等待超时时
工具函数按以下优先级查找触发器(与静态 Select 相同):
[data-testid="${label}-trigger"][aria-label="${label}"][role="combobox"]text="${label}"选项选择策略:
[role="option"][data-value="${value}"][role="option"]:text-is("${value}")selectRadixOptionAsync 的等待策略:
// 内部实现流程
async function selectRadixOptionAsync(page, label, value, options?) {
const config = {
timeout: options?.timeout ?? 5000,
waitForOption: options?.waitForOption ?? true,
waitForNetworkIdle: options?.waitForNetworkIdle ?? true
};
// 1. 查找并点击触发器
const trigger = await findTrigger(page, label, value);
await trigger.click();
// 2. 等待选项列表容器出现
await page.waitForSelector('[role="listbox"]', {
timeout: 2000,
state: 'visible'
});
// 3. 等待网络空闲(可配置)
if (config.waitForNetworkIdle) {
try {
await page.waitForLoadState('networkidle', { timeout: config.timeout });
} catch (err) {
console.debug('网络空闲等待超时,继续尝试选择选项', err);
}
}
// 4. 使用重试机制等待选项并选择
if (config.waitForOption) {
await waitForOptionAndSelect(page, value, config.timeout);
}
}
重试机制:
timeout 毫秒文件位置: web/tests/e2e/pages/admin/disability-person.page.ts
需要修改的代码(第 86-94 行):
// 当前实现(需要修改的部分)
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);
await this.page.getByLabel('身份证号 *').fill(data.idCard);
await this.page.getByLabel('残疾证号 *').fill(data.disabilityId);
await selectRadixOption(this.page, '残疾类型 *', data.disabilityType); // 静态 Select,已迁移
await selectRadixOption(this.page, '残疾等级 *', data.disabilityLevel); // 静态 Select,已迁移
await this.page.getByLabel('联系电话 *').fill(data.phone);
await this.page.getByLabel('身份证地址 *').fill(data.idAddress);
// 居住地址 - 使用 Radix UI Select(异步加载)
await this.selectRadixOption('省份 *', data.province); // ❌ 需要替换
await this.page.waitForTimeout(500); // ❌ hack: 等待城市加载,需要移除
await this.selectRadixOption('城市', data.city); // ❌ 需要替换
}
// 第 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}`);
}
| 步骤 | 操作 | 位置 | 详情 |
|---|---|---|---|
| 1 | 更新导入 | 第 2 行后 | 添加 selectRadixOptionAsync 到导入语句 |
| 2 | 替换省份调用 | 第 92 行 | await selectRadixOptionAsync(this.page, '省份 *', data.province) |
| 3 | 删除等待 hack | 第 93 行 | 删除 await this.page.waitForTimeout(500) |
| 4 | 替换城市调用 | 第 94 行 | await selectRadixOptionAsync(this.page, '城市', data.city) |
| 5 | 删除自定义方法 | 第 97-105 行 | 完全删除自定义 selectRadixOption 方法 |
修改前:
import { selectRadixOption } from '@d8d/e2e-test-utils';
async fillBasicForm(data: { /* ... */ }) {
// ... 其他字段
await this.selectRadixOption('省份 *', data.province); // 自定义方法
await this.page.waitForTimeout(500); // hack
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, selectRadixOptionAsync } from '@d8d/e2e-test-utils';
async fillBasicForm(data: { /* ... */ }) {
// ... 其他字段
await selectRadixOptionAsync(this.page, '省份 *', data.province); // 工具函数
// 无需等待 hack,工具函数自动处理
await selectRadixOptionAsync(this.page, '城市', data.city); // 工具函数
}
// 自定义方法已完全移除
单一职责:
selectRadixOptionAsync 专注于异步 Select 选择错误处理:
E2ETestError 提供结构化错误信息超时配置:
DEFAULT_TIMEOUTS.async)options.timeout 自定义工具包类型定义:
// packages/e2e-test-utils/src/radix-select.ts
export async function selectRadixOptionAsync(
page: Page,
label: string,
value: string,
options?: AsyncSelectOptions
): Promise<void>
// packages/e2e-test-utils/src/types.ts
export interface AsyncSelectOptions extends BaseOptions {
timeout?: number;
waitForOption?: boolean;
waitForNetworkIdle?: boolean;
}
使用时类型检查:
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 { selectRadixOptionAsync } from './radix-select.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 # selectRadixOptionAsync 实现
│ ├── types.ts # AsyncSelectOptions 类型定义
│ ├── 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: '视力残疾',
disabilityLevel: '一级',
phone: '13800138000',
idAddress: '北京市东城区',
province: '广东省', // 异步加载,使用 selectRadixOptionAsync
city: '深圳市', // 根据省份动态加载,使用 selectRadixOptionAsync
});
预期结果:
waitForTimeout(500) hack| 操作 | 目标时间 | 最大可接受时间 |
|---|---|---|
| 省份选择(异步) | < 3s | 5s |
| 城市选择(异步) | < 3s | 5s |
来源: docs/standards/e2e-radix-testing.md 中的性能标准
1. 工具函数调用方式:
page 对象作为第一个参数selectRadixOption(page, label, value)this.selectRadixOption(label, value) 不同2. 渐进式迁移策略:
3. 保留和删除的决策:
4. 导入语句:
selectRadixOptionselectRadixOption 和 selectRadixOptionAsync
selectRadixOption 用于 addBankCard 和 addVisit 中的静态 SelectselectRadixOptionAsync 用于 fillBasicForm 中的异步 Select等待 hack 需要移除:
await this.page.waitForTimeout(500);selectRadixOptionAsync 自动处理,无需此 hack1. TypeScript + Playwright DOM 类型问题:
page.evaluate() 获取文本page.locator().allTextContents()2. 精确文本匹配:
:has-text() 会部分匹配,可能误选:text-is() 进行精确匹配3. 网络空闲等待超时配置 Bug(Story 1.6 发现):
// ❌ Bug - 网络空闲等待使用了默认超时
await page.waitForLoadState('networkidle', { timeout: DEFAULT_TIMEOUTS.networkIdle });
// ✅ 修复 - 使用用户自定义的 timeout
await page.waitForLoadState('networkidle', { timeout: options.timeout ?? DEFAULT_TIMEOUTS.async });
selectRadixOptionAsync 使用正确的配置4. 代码审查发现的问题类型:
最近 5 次提交:
d307761 docs(e2e-test-utils): 完成 Story 2.2 代码审查
07814c2 docs(e2e-test-utils): 创建 Story 2.2 重写静态 Select 工具
02ece3b fix(e2e-test-utils): 完成 Story 2.1 代码审查修复
35bde40 docs(e2e-test-utils): 创建 Story 2.1 安装 e2e-utils 包
54bded5 docs: 完成 Epic 1 回顾及技术改进
相关文件修改历史:
web/tests/e2e/pages/admin/disability-person.page.ts
代码模式:
完整项目上下文: _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/2-2-rewrite-static-select.md_bmad-output/implementation-artifacts/epic-1-retrospective.mdpackages/e2e-test-utils/src/radix-select.tsdocs/standards/e2e-radix-testing.md完成本故事后,继续:
Claude Opus 4.5 (claude-opus-4-5-20251101)
(开发过程中添加调试日志引用)
创建时间: 2026-01-09
创建内容:
实现完成时间: 2026-01-09
实现验证:
selectRadixOptionAsync 已正确导入selectRadixOptionAsync(this.page, '省份 *', data.province) (第 92 行)selectRadixOptionAsync(this.page, '城市', data.city) (第 93 行)waitForTimeout(500) hack 已移除(省份/城市之间无等待)selectRadixOption 方法已完全移除注意事项:
async-select-test.spec.ts 初始创建时缺少登录步骤,导致测试被重定向到登录页面test-setup.ts fixtures 和 test.describe.serial)修改的文件:
web/tests/e2e/pages/admin/disability-person.page.tspackages/e2e-test-utils/src/radix-select.ts (工具包增强)packages/e2e-test-utils/tests/unit/radix-select.test.ts (新增策略 3 和 4 的单元测试)web/tests/e2e/specs/admin/async-select-test.spec.ts (修复登录问题)修改详情:
1. disability-person.page.ts:
selectRadixOption, selectRadixOptionAsyncawait selectRadixOptionAsync(this.page, '省份 *', data.province)await selectRadixOptionAsync(this.page, '城市', data.city)waitForTimeout(500) hack2. radix-select.ts (工具包增强):
getByRole("combobox", { name: label }) 查找触发器console.log 改为 console.debug(符合项目规范)DEFAULT_TIMEOUTS.staticselectRadixOptionAsync 添加与 selectRadixOption 一致的调试日志3. radix-select.test.ts (单元测试):
getByRole)的单元测试4. async-select-test.spec.ts:
adminLoginPage fixture)disabilityPersonPage.openCreateDialog() 代替手动点击创建时间: 2026-01-09
实现完成时间: 2026-01-09
代码审查修复时间: 2026-01-09
实现内容:
selectRadixOptionAsyncwaitForTimeout(500) hackselectRadixOption 方法findTrigger 函数的选择器策略(新增策略 3 和 4)代码审查修复 (2026-01-09):
async-select-test.spec.ts 和 radix-select.ts 到文件列表async-select-test.spec.ts 中的 waitForTimeout(500) hack单元测试结果: