Explorar el Código

docs(e2e-test-utils): 创建 Story 2.3 重写异步 Select 工具

- 创建完整 Story 2.3 文档(2-3-rewrite-async-select.md)
- 替换省份/城市选择为 selectRadixOptionAsync
- 移除 waitForTimeout(500) hack
- 移除自定义 selectRadixOption 方法
- 更新 sprint-status.yaml 状态为 ready-for-dev

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname hace 1 semana
padre
commit
b4cba93e4d

+ 676 - 0
_bmad-output/implementation-artifacts/2-3-rewrite-async-select.md

@@ -0,0 +1,676 @@
+# Story 2.3: 使用 selectRadixOptionAsync 重写省份/城市选择
+
+Status: ready-for-dev
+
+<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
+
+## 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. 导入工具函数
+
+在文件顶部添加导入(需要同时保留静态和异步函数):
+
+```typescript
+// 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):**
+```typescript
+// this.selectRadixOption 是类方法
+await this.selectRadixOption('省份 *', data.province);
+await this.page.waitForTimeout(500);  // hack: 等待城市加载
+await this.selectRadixOption('城市', data.city);
+```
+
+**新实现(工具函数 + 自动等待):**
+```typescript
+// 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` 工具函数
+- 自定义方法已无任何用途,应完全移除
+
+**移除的代码:**
+```typescript
+// 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` 内置的等待策略:
+
+```typescript
+// 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` 包:
+
+```typescript
+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<void>
+
+**异常:** `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` 的等待策略:
+
+```typescript
+// 内部实现流程
+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 行):**
+
+```typescript
+// 当前实现(需要修改的部分)
+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. 完整修改示例
+
+**修改前:**
+```typescript
+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}`);
+}
+```
+
+**修改后:**
+```typescript
+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 严格模式
+
+**工具包类型定义:**
+```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
+
+**导出的异步函数:**
+```typescript
+// 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. 代码修改完成后:**
+```bash
+# 类型检查
+pnpm typecheck
+```
+
+**2. 运行测试:**
+```bash
+# 运行残疾人管理完整流程测试
+pnpm test:e2e:chromium disability-person-complete.spec.ts
+```
+
+**3. 观察点:**
+- ✅ 省份选择正常完成(异步加载)
+- ✅ 城市选择正常完成(根据省份动态加载)
+- ✅ 无 `E2ETestError` 错误
+- ✅ 无 flaky 失败
+- ✅ 测试通过
+
+#### 预期测试行为
+
+**测试场景:**
+```typescript
+// 测试代码会调用 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 需要同时导入 `selectRadixOption` 和 `selectRadixOptionAsync`
+  - `selectRadixOption` 用于 `addBankCard` 和 `addVisit` 中的静态 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 发现):**
+```typescript
+// ❌ 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 的技术回顾

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

@@ -55,7 +55,7 @@ development_status:
   epic-2: in-progress
   2-1-install-e2e-utils: done
   2-2-rewrite-static-select: done
-  2-3-rewrite-async-select: backlog
+  2-3-rewrite-async-select: ready-for-dev
   2-4-run-tests-collect-feedback: backlog
   2-5-fix-found-issues: backlog
   2-6-stability-verification: backlog