# @d8d/e2e-test-utils

> E2E 测试工具集 - 专门用于测试 Radix UI 组件的 Playwright 工具函数

[![TypeScript](https://img.shields.io/badge/TypeScript-5.8+-blue)](https://www.typescriptlang.org/)
[![Playwright](https://img.shields.io/badge/Playwright-1.40+-green)](https://playwright.dev/)
[![Vitest](https://img.shields.io/badge/Vitest-3.2+-purple)](https://vitest.dev/)

## 📋 简介

`@d8d/e2e-test-utils` 是一个专为 Radix UI 组件设计的 E2E 测试工具集。它提供了一组强大且易于使用的函数，帮助你更高效地编写和维护端到端测试。

### 核心特性

- 🎯 **Radix UI 专用** - 针对无障碍组件优化的选择器策略
- 🔒 **TypeScript 全支持** - 完整的类型定义和 JSDoc 注释
- 📦 **零运行时依赖** - 仅依赖 Playwright 作为 peer dependency
- 🧪 **开箱即用的测试数据** - 包含常用测试场景的 fixtures
- 🌳 **Tree-shakeable** - 按需导入，只打包使用的代码
- ⚡ **严格模式** - 启用 TypeScript 严格类型检查

## 📦 安装

### Monorepo 项目（Workspace）

```bash
pnpm add -D @d8d/e2e-test-utils@workspace:*
```

### 独立项目

```bash
pnpm add -D @d8d/e2e-test-utils
```

### Peer Dependencies

确保你的项目已安装 `@playwright/test`：

```bash
pnpm add -D @playwright/test
```

### TypeScript 配置

确保你的 `tsconfig.json` 包含以下配置以获得最佳类型支持：

```json
{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}
```

如果你使用 Node.js 16+，也可以使用 `node16` 或 `nodenext` 模块解析。

## 🚀 快速入门

### Select 工具使用

```typescript
import { test, expect } from '@playwright/test';
import { selectRadixOption, selectRadixOptionAsync } from '@d8d/e2e-test-utils';

test('选择静态下拉框选项', async ({ page }) => {
  await page.goto('/form');

  // 选择静态下拉框（如残疾类型、性别等枚举类型）
  await selectRadixOption(page, '残疾类型', '视力残疾');
  await selectRadixOption(page, '性别', '男');
});

test('选择异步加载的下拉框选项', async ({ page }) => {
  await page.goto('/form');

  // 选择异步加载的下拉框（如省份、城市、银行等动态数据）
  await selectRadixOptionAsync(page, '省份', '广东省');
  await selectRadixOptionAsync(page, '城市', '深圳市');
});
```

### 选择器策略

工具函数按以下优先级查找下拉框触发器：

1. **`data-testid="标签-trigger"`** - 推荐，最稳定
2. **`aria-label="标签"` + `role="combobox"`** - 无障碍属性
3. **`text="标签"`** - 文本匹配（兜底）

**推荐做法**：在 Radix Select 组件上添加 `data-testid` 属性以获得最佳稳定性。

```tsx
<RadixSelect.Root>
  <RadixSelect.Trigger data-testid="省份-trigger">
    <RadixSelect.Value placeholder="选择省份" />
  </RadixSelect.Trigger>
  {/* ... */}
</RadixSelect.Root>
```

### 基础使用

```typescript
import { test, expect } from '@playwright/test';
import { BaseOptions, E2ETestError, DEFAULT_TIMEOUTS } from '@d8d/e2e-test-utils';

test('示例测试', async ({ page }) => {
  // 使用工具函数进行测试
  const options: BaseOptions = {
    timeout: DEFAULT_TIMEOUTS.static
  };

  // 测试逻辑...
});
```

### 使用类型定义

```typescript
import type { BaseOptions, ErrorContext } from '@d8d/e2e-test-utils';

// 定义自定义选项
const options: BaseOptions = {
  timeout: 5000
};

// 构建错误上下文
const errorContext: ErrorContext = {
  operation: 'selectOption',
  target: 'dropdown',
  expected: 'Option A',
  actual: 'Option B',
  available: ['Option A', 'Option B', 'Option C'],
  suggestion: '检查选项值是否正确'
};
```

### 使用错误类

```typescript
import { E2ETestError } from '@d8d/e2e-test-utils';

test('错误处理示例', async ({ page }) => {
  const selectElement = await page.$('[data-testid="my-select"]');

  if (!selectElement) {
    throw new E2ETestError({
      operation: 'findSelect',
      target: '[data-testid="my-select"]',
      suggestion: '确认 data-testid 属性是否正确设置'
    });
  }
});
```

### 使用测试数据

**注意**：测试数据文件（fixtures）仅在开发环境可用，不会随 npm 包发布。

**Monorepo 开发环境：**

```typescript
import testUsers from '@d8d/e2e-test-utils/tests/fixtures/data/test-users.json' assert { type: 'json' };

test('用户登录', async ({ page }) => {
  const user = testUsers.users[0];

  await page.goto('/login');
  await page.fill('[name="email"]', user.email);
  await page.fill('[name="password"]', 'test_password');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
});
```

**独立项目：**

在您的项目中创建测试数据文件：

```bash
mkdir -p tests/fixtures/data
cat > tests/fixtures/data/test-users.json << 'EOF'
{
  "users": [
    { "name": "Test User", "email": "test@example.com" }
  ]
}
EOF
```

然后导入使用：

```typescript
import testUsers from './fixtures/data/test-users.json' assert { type: 'json' };
```

## 📚 API 文档

### 类型定义

#### `BaseOptions`

基础配置选项，所有工具函数配置对象的基类。

```typescript
interface BaseOptions {
  /** 超时时间（毫秒）*/
  timeout?: number;
}
```

#### `ErrorContext`

错误上下文信息，用于结构化错误报告。

```typescript
interface ErrorContext {
  /** 操作类型（如 'selectRadixOption'）*/
  operation: string;
  /** 目标（如下拉框标签）*/
  target: string;
  /** 期望值 */
  expected?: string;
  /** 实际值 */
  actual?: string;
  /** 可用选项列表 */
  available?: string[];
  /** 修复建议 */
  suggestion?: string;
}
```

### 错误类

#### `E2ETestError`

E2E 测试专用错误类，提供结构化的错误上下文信息。

```typescript
class E2ETestError extends Error {
  constructor(
    public readonly context: ErrorContext,
    message?: string
  )
}
```

**示例：**

```typescript
throw new E2ETestError({
  operation: 'selectOption',
  target: 'Role Selector',
  expected: 'Admin',
  actual: 'User',
  available: ['Admin', 'User', 'Guest'],
  suggestion: '确认选项值是否正确拼写'
});
// 输出:
// ❌ selectOption failed
// Target: Role Selector
// Expected: Admin
// Actual: User
// Available: Admin, User, Guest
// 💡 确认选项值是否正确拼写
```

### 常量

#### `DEFAULT_TIMEOUTS`

默认超时配置（毫秒）。

```typescript
const DEFAULT_TIMEOUTS = {
  /** 静态选项超时 */
  static: 2000,
  /** 异步选项超时 */
  async: 5000,
  /** 网络空闲超时 */
  networkIdle: 10000
} as const;
```

**使用示例：**

```typescript
import { DEFAULT_TIMEOUTS } from '@d8d/e2e-test-utils';

await page.waitForTimeout(DEFAULT_TIMEOUTS.static);
```

#### `SELECTOR_STRATEGIES`

支持的选择器策略列表。

```typescript
const SELECTOR_STRATEGIES = [
  'data-testid',
  'aria-label + role',
  'text content + role'
] as const;
```

**策略优先级：**
1. `data-testid` - 最高优先级，最稳定
2. `aria-label + role` - 遵循无障碍标准
3. `text content + role` - 兜底方案

---

### Radix UI Select 工具

#### `selectRadixOption()`

选择静态枚举型 Radix UI Select 选项。适用于选项在页面加载时已存在于 DOM 中的下拉框。

**函数签名：**

```typescript
function selectRadixOption(
  page: Page,
  label: string,
  value: string
): Promise<void>
```

**参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `page` | `Page` | ✅ | Playwright Page 对象 |
| `label` | `string` | ✅ | 下拉框触发器的标签文本（data-testid/aria-label/文本内容） |
| `value` | `string` | ✅ | 要选择的选项值 |

**使用场景：**
- 枚举类型选择（残疾类型、性别、婚姻状况等）
- 静态配置选项
- 选项在页面加载时已存在 DOM 中

**示例：**

```typescript
import { selectRadixOption } from '@d8d/e2e-test-utils';

test('填写残疾人信息', async ({ page }) => {
  await page.goto('/disabled-person-form');

  // 选择残疾类型
  await selectRadixOption(page, '残疾类型', '视力残疾');

  // 选择性别
  await selectRadixOption(page, '性别', '男');

  // 选择婚姻状况
  await selectRadixOption(page, '婚姻状况', '未婚');
});
```

---

#### `selectRadixOptionAsync()`

选择异步加载的 Radix UI Select 选项。适用于选项需要从 API 动态加载的下拉框。

**函数签名：**

```typescript
function selectRadixOptionAsync(
  page: Page,
  label: string,
  value: string,
  options?: AsyncSelectOptions
): Promise<void>
```

**参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `page` | `Page` | ✅ | Playwright Page 对象 |
| `label` | `string` | ✅ | 下拉框触发器的标签文本 |
| `value` | `string` | ✅ | 要选择的选项值 |
| `options` | `AsyncSelectOptions` | ❌ | 可选配置对象 |
| `options.timeout` | `number` | ❌ | 超时时间（毫秒），默认 5000 |
| `options.waitForOption` | `boolean` | ❌ | 是否等待选项加载，默认 true |
| `options.waitForNetworkIdle` | `boolean` | ❌ | 是否等待网络空闲，默认 true |

**使用场景：**
- 动态数据选择（省份、城市、银行等）
- 选项从 API 加载
- 需要等待网络请求完成

**示例：**

```typescript
import { selectRadixOptionAsync } from '@d8d/e2e-test-utils';

test('填写地址信息', async ({ page }) => {
  await page.goto('/address-form');

  // 选择省份（使用默认配置）
  await selectRadixOptionAsync(page, '省份', '广东省');

  // 选择城市（自定义超时时间）
  await selectRadixOptionAsync(page, '城市', '深圳市', {
    timeout: 10000
  });

  // 选择银行（禁用网络空闲等待，适用于网络不稳定环境）
  await selectRadixOptionAsync(page, '开户银行', '中国工商银行', {
    waitForNetworkIdle: false
  });
});
```

---

### 静态 Select vs 异步 Select

| 特性 | 静态 Select (`selectRadixOption`) | 异步 Select (`selectRadixOptionAsync`) |
|------|----------------------------------|----------------------------------------|
| **选项加载时机** | 页面加载时已存在 DOM 中 | 点击触发器后 API 加载 |
| **使用场景** | 枚举类型（残疾类型、性别等） | 动态数据（省份、城市、银行等） |
| **等待策略** | 立即查找选项 | 等待网络请求 + 选项出现 |
| **默认超时** | 2000ms | 5000ms |
| **配置对象** | 无 | `AsyncSelectOptions` |
| **网络空闲等待** | 不需要 | 默认启用 |
| **函数签名** | `selectRadixOption(page, label, value)` | `selectRadixOptionAsync(page, label, value, options?)` |

**选择建议：**

- ✅ 如果下拉框选项在页面加载时已存在 → 使用 `selectRadixOption()`
- ✅ 如果下拉框选项需要从 API 动态加载 → 使用 `selectRadixOptionAsync()`
- 🤔 不确定时，可以使用异步版本（会有额外等待，但更稳定）

**实际案例对比：**

```typescript
// 静态 Select - 残疾类型（枚举）
await selectRadixOption(page, '残疾类型', '视力残疾');
// ↓ 点击 → 立即查找选项 → 选择

// 异步 Select - 省份（API 加载）
await selectRadixOptionAsync(page, '省份', '广东省');
// ↓ 点击 → 等待网络请求 → 等待选项出现 → 选择
```

## 🧪 测试

### 运行测试

```bash
# 运行所有单元测试
pnpm test:unit

# 运行测试并生成覆盖率报告
pnpm test:coverage

# 监听模式（开发时使用）
pnpm test
```

### 测试结构

```
tests/
├── fixtures/           # 测试资源
│   ├── data/          # 测试数据（JSON）
│   └── images/        # 测试图片
├── unit/              # 单元测试（Vitest）
├── integration/       # 集成测试（Playwright）
└── stability/         # 稳定性测试
```

### 使用 Fixtures

#### 数据文件

```typescript
// 导入测试用户数据
import testUsers from '@d8d/e2e-test-utils/tests/fixtures/data/test-users.json' assert { type: 'json' };

const user = testUsers.users[0];
console.log(user.name, user.email);
```

#### 图片文件

图片文件用于测试文件上传功能。请参考 `tests/fixtures/images/README.md` 了解如何添加测试图片。

## 🛠️ 开发

### 项目结构

```
packages/e2e-test-utils/
├── src/
│   ├── index.ts              # 主导出，tree-shakeable
│   ├── types.ts              # 共享类型定义
│   ├── errors.ts             # 错误类和错误处理
│   ├── constants.ts          # 常量定义
│   ├── radix-select.ts       # Radix UI Select 工具
│   ├── file-upload.ts        # 文件上传工具（规划中）
│   ├── form-helper.ts        # 表单辅助函数（规划中）
│   ├── dialog.ts             # 对话框操作（规划中）
│   └── dynamic-list.ts       # 动态列表管理（规划中）
├── tests/
│   ├── fixtures/             # 测试资源
│   ├── unit/                 # Vitest 单元测试
│   ├── integration/          # Playwright 集成测试
│   └── stability/            # 稳定性测试
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── README.md
```

### 可用脚本

```bash
# 类型检查
pnpm typecheck

# 构建包
pnpm build

# 开发模式（监听文件变化）
pnpm dev

# 运行测试
pnpm test:unit

# 生成覆盖率报告
pnpm test:coverage
```

## 🤝 贡献

欢迎贡献！请遵循以下步骤：

1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'Add some amazing feature'`)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 开启 Pull Request

### 代码规范

- 使用 TypeScript 严格模式
- 所有导出的函数/类型必须有完整的 JSDoc 注释
- 单元测试覆盖率 ≥ 80%
- 遵循项目的 ESLint 和 Prettier 配置

## 📝 License

MIT

## 🔗 相关资源

- [Radix UI](https://www.radix-ui.com/) - 无障碍 UI 组件库
- [Playwright](https://playwright.dev/) - E2E 测试框架
- [TypeScript](https://www.typescriptlang.org/) - 类型安全
