|
|
@@ -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 的技术回顾
|