2-3-rewrite-async-select.md 22 KB

Story 2.3: 使用 selectRadixOptionAsync 重写省份/城市选择

Status: ready-for-dev

Story

作为测试开发者, 我想要使用 selectRadixOptionAsync() 处理异步加载的 Select, 以便验证工具在异步 Select 场景中的可用性。

Acceptance Criteria

  1. Given @d8d/e2e-test-utils 已安装(Story 2.1 完成)
  2. When 修改 web/tests/e2e/pages/admin/disability-person.page.ts
  3. Then fillBasicForm() 中的省份选择使用 selectRadixOptionAsync()
  4. And fillBasicForm() 中的城市选择使用 selectRadixOptionAsync()
  5. And 移除 waitForTimeout(500) 等待城市加载的 hack
  6. And 移除自定义的 selectRadixOption() 方法(第 97-105 行)
  7. And 测试通过,功能正常

Tasks / Subtasks

  • 导入 selectRadixOptionAsync 工具函数 (AC: #1)
    • 在文件顶部添加 import { selectRadixOption, selectRadixOptionAsync } from '@d8d/e2e-test-utils'
  • 替换 fillBasicForm 中的异步 Select 调用 (AC: #3, #4)
    • 替换省份选择:await selectRadixOptionAsync(this.page, '省份 *', data.province)
    • 替换城市选择:await selectRadixOptionAsync(this.page, '城市', data.city)
  • 移除等待 hack (AC: #5)
    • 删除 await this.page.waitForTimeout(500)
  • 移除自定义 selectRadixOption 方法 (AC: #6)
    • 删除第 97-105 行的自定义方法及其 TODO 注释
  • 验证测试通过 (AC: #7)
    • 运行 pnpm test:e2e:chromium disability-person-complete.spec.ts
    • 确认所有 Select 操作正常工作

Dev Notes

Epic Context

Epic 2 目标:web/tests/e2e/ 的现有残疾人管理测试中使用 Select 工具,验证工具在真实场景中的可用性和稳定性。

Epic 2 范围:

  • ✅ 使用现有 web/tests/e2e/ 测试基础设施
  • ✅ 使用现有的残疾人管理测试场景
  • ❌ 不创建新的测试应用
  • ❌ 不添加新功能(仅验证现有功能)

验证场景

异步 Select 场景(本故事):

  • 省份选择(异步加载选项,触发 API 请求)
  • 城市选择(根据省份动态加载)

配置要点:

  • 使用 waitForOption: true 等待选项加载
  • 使用 waitForNetworkIdle: true 确保数据加载完成
  • 默认超时配置 5 秒(DEFAULT_TIMEOUTS.async

实现要点

1. 导入工具函数

在文件顶部添加导入(需要同时保留静态和异步函数):

// 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。

2. 替换调用方式

原实现(自定义方法 + 等待 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,工具函数自动处理异步加载

3. 需要修改的位置

方法 行号 Select 字段 标签文本 当前实现
fillBasicForm 92 省份 省份 * this.selectRadixOption
fillBasicForm 93 (等待 hack) - waitForTimeout(500)
fillBasicForm 94 城市 城市 this.selectRadixOption

4. 移除自定义方法

技术决策: 完全移除第 97-105 行的自定义 selectRadixOption 方法。

原因:

  • Story 2.2 已将所有静态 Select 迁移到 selectRadixOption 工具函数
  • 本故事将异步 Select 迁移到 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}`);
}

5. 工具函数的自动等待机制

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
  • 更可靠、更清晰

Project Structure Notes

文件路径:

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

References

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


Developer Context

重要提示: 本部分包含开发者实现此故事所需的所有关键上下文和约束条件。

技术需求

1. 工具函数签名

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 - 当触发器未找到或等待超时时

2. 选择器策略

工具函数按以下优先级查找触发器(与静态 Select 相同):

  1. data-testid(推荐): [data-testid="${label}-trigger"]
  2. aria-label + role: [aria-label="${label}"][role="combobox"]
  3. 文本匹配(兜底): text="${label}"

选项选择策略:

  1. data-value(精确匹配): [role="option"][data-value="${value}"]
  2. 精确文本匹配: [role="option"]:text-is("${value}")

3. 异步等待机制

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);
  }
}

重试机制:

  • 每 100ms 重试一次
  • 最多重试 timeout 毫秒
  • 先尝试 data-value 策略,再尝试精确文本匹配

4. 当前实现分析

文件位置: 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}`);
}

5. 修改清单

步骤 操作 位置 详情
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 方法

6. 完整修改示例

修改前:

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 选择
  • 自动处理网络请求完成和选项加载
  • 无需手动等待或 hack

错误处理:

  • 使用 E2ETestError 提供结构化错误信息
  • 错误包含:操作类型、目标、期望值、可用选项、修复建议
  • 超时时提供清晰的错误消息

超时配置:

  • 异步 Select 默认超时:5000ms(DEFAULT_TIMEOUTS.async
  • 可通过 options.timeout 自定义
  • 使用 Playwright 的 auto-waiting 机制

TypeScript 严格模式

工具包类型定义:

// 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;
}

使用时类型检查:

  • IDE 自动补全函数参数和配置选项
  • 编译时检查参数类型
  • 错误时抛出 E2ETestError

库和框架要求

Playwright 版本

版本 要求
@playwright/test (web) 1.55.0 ✅ 满足 ^1.40.0
@d8d/e2e-test-utils - peer dependency: ^1.40.0

工具包 API

导出的异步函数:

// 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 错误
  • ✅ 无 flaky 失败
  • ✅ 测试通过

预期测试行为

测试场景:

// 测试代码会调用 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 中的性能标准

上一个故事的经验(Story 2.2)

Story 2.2 关键经验总结

1. 工具函数调用方式:

  • 需要显式传入 page 对象作为第一个参数
  • 函数签名:selectRadixOption(page, label, value)
  • 这与类方法 this.selectRadixOption(label, value) 不同

2. 渐进式迁移策略:

  • Story 2.2 先迁移静态 Select
  • Story 2.3 再迁移异步 Select
  • 这样可以保持测试连续性,逐步验证

3. 保留和删除的决策:

  • Story 2.2 保留自定义方法用于异步 Select(添加 TODO 注释)
  • Story 2.3 完全移除自定义方法

4. 导入语句:

  • Story 2.2 只导入 selectRadixOption
  • Story 2.3 需要同时导入 selectRadixOptionselectRadixOptionAsync
    • selectRadixOption 用于 addBankCardaddVisit 中的静态 Select
    • selectRadixOptionAsync 用于 fillBasicForm 中的异步 Select

Story 2.2 遗留的问题

等待 hack 需要移除:

  • 第 93 行:await this.page.waitForTimeout(500);
  • 这是临时解决方案,等待城市选项加载
  • selectRadixOptionAsync 自动处理,无需此 hack

Epic 1 回顾经验(技术经验)

关键技术经验 [来源: epic-1-retrospective.md]

1. TypeScript + Playwright DOM 类型问题:

  • ❌ 避免:使用 page.evaluate() 获取文本
  • ✅ 推荐:使用 Playwright API: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 });
  • 这个 bug 已在 Story 1.6 中修复
  • selectRadixOptionAsync 使用正确的配置

4. 代码审查发现的问题类型:

  • HIGH: DOM 类型问题、精确文本匹配
  • MEDIUM: 错误消息不清晰
  • LOW: 代码风格

Git Intelligence

最近 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
    • Story 2.1: 添加注释说明工具包已安装
    • Story 2.2: 替换静态 Select 调用(残疾类型、残疾等级、银行名称等)
    • Story 2.3: 将替换异步 Select 调用(省份、城市)

代码模式:

  • 提交信息使用中文
  • 使用 conventional commits 格式(feat, fix, docs 等)

项目上下文引用

完整项目上下文: _bmad-output/project-context.md

关键规范:

  • 测试框架:Playwright 1.55.0
  • 包管理:pnpm workspace 协议
  • TypeScript:严格模式,无 any 类型
  • 测试命令:pnpm test:e2e:chromium

相关文档:

  • 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:_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

下一步

完成本故事后,继续:

  • Story 2.4: 运行测试并收集问题和改进建议
  • Story 2.5: 修复发现的问题
  • Story 2.6: 稳定性验证

Dev Agent Record

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

Debug Log References

(开发过程中添加调试日志引用)

Completion Notes List

创建时间: 2026-01-09

创建内容:

  • 完整的 Story 2.3 文档,包含:
    • 用户故事和验收标准
    • 详细的任务分解
    • 开发者上下文(技术需求、架构合规性、测试要求)
    • Story 2.2 和 Epic 1 的经验总结
    • Git 情报和项目上下文引用

File List

预期修改的文件:

  • web/tests/e2e/pages/admin/disability-person.page.ts

预期修改详情:

  • 第 2 行:更新导入,添加 selectRadixOptionAsync
  • 第 92 行:替换省份选择为 selectRadixOptionAsync
  • 第 93 行:删除 waitForTimeout(500) hack
  • 第 94 行:替换城市选择为 selectRadixOptionAsync
  • 第 97-105 行:完全删除自定义 selectRadixOption 方法

Change Log

创建时间: 2026-01-09

创建内容:

  • 完整的 Story 2.3 文档
  • 包含所有必要的开发者上下文和技术细节
  • 引用 Story 2.2 的经验和 Epic 1 的技术回顾