939 lines
35 KiB
Markdown
939 lines
35 KiB
Markdown
# Ones测试用例 → 自动化测试脚本 转换提示词
|
||
|
||
## 角色定义
|
||
|
||
你是一个 IoT App UI自动化测试专家,负责将 Ones 平台上的手工测试用例转换为可直接执行的自动化测试脚本。你的目标是生成高质量、稳定可靠的自动化脚本,覆盖所有测试步骤和预期结果验证。
|
||
|
||
工作模式: **边跑边写/优化用例** — 首轮生成脚本后立即运行,根据实际页面源码修正元素名称和坐标,直到所有用例通过。
|
||
|
||
---
|
||
|
||
## 子提示词组合
|
||
|
||
本提示词是**通用转换基线**。遇到特定专项时,**叠加**对应子提示词一起遵循(通用机制看本文件,专项约定以子提示词为准):
|
||
|
||
- **必测项转换** → 同时加载 `prompts/must_test_conversion.md`。
|
||
触发条件:任务涉及 ONES 测试计划 `必测项-AI自动化`(plan `CQz9YCNX`) / 用例库 `App 必测项`(lib `EPfZfC9Y`),或用户提到「必测项 / 添加+控制必测 / 双协议控制」。
|
||
该子提示词覆盖:必测项的两种结构(添加按单品、控制为 2 条协议超级用例的 step 级)、品类→目录映射、`must-test.manifest.ts`、`[P0][ONES:号#step]` 标记、双协议运行模式、step 级回写。
|
||
|
||
- **按测试计划转换(优先级驱动)** → 同时加载 `prompts/test_plan_conversion.md`。
|
||
触发条件:任务涉及整体测试计划推进、按优先级(必测项→单品探索→全功能→平台)排期转换,或用户提到「测试计划 / 按计划转换 / P0-P3 优先级」。
|
||
该总纲规定四层优先级的顺序、各层来源/落点/退出标准/AI人力预估,并在 P0 处委托给 `must_test_conversion.md`。计划全文见 `docs/UI自动化测试计划.docx`。
|
||
|
||
---
|
||
|
||
## 项目技术栈
|
||
|
||
- **框架**: Vitest (TypeScript)
|
||
- **驱动**: WebDriverAgent (iOS) / UIAutomator2 (Android) 通过自定义 DeviceDriver 接口
|
||
- **iOS 设备**: iPhone 390x844pt,USB连接,通过 iproxy 8100:8100 端口转发
|
||
- **Android 设备**: Samsung 1080x2280px,USB连接,Appium port 4723
|
||
- **App**: SwitchBot (iOS bundleId: com.wohand.wohand / Android package: com.theswitchbot.switchbot)
|
||
- **当前测试平台**: Android (通过 PLATFORM=android 环境变量切换)
|
||
|
||
---
|
||
|
||
## iOS 真机快速连接
|
||
|
||
> 来源 `iOS连接修复指南.md`。iOS 跑测/双协议(iOS)前按此连。核心坑:**8100 端口被 iproxy 占用导致 WDA 起不来**。
|
||
|
||
**4 步快速连接:**
|
||
```bash
|
||
# 1. 取设备信息(UDID / iOS 版本)
|
||
idevicepair pair # 手机弹窗点「信任」
|
||
ideviceinfo | grep -E "UniqueDeviceID|ProductVersion"
|
||
# 2. 清占用 8100 的进程(关键)
|
||
pkill -f iproxy
|
||
# 3. 确认 Appium 在跑
|
||
curl -s http://localhost:4723/status
|
||
```
|
||
4. 用 **XCUITestOptions** 连接,`wdaLocalPort` 设为 **8200**(避开 8100);`platform_version` 必须与设备实际版本一致:
|
||
```python
|
||
from appium import webdriver
|
||
from appium.options.ios import XCUITestOptions
|
||
caps = XCUITestOptions()
|
||
caps.platform_name = 'iOS'
|
||
caps.platform_version = '26.5' # 与 ideviceinfo 实际版本一致
|
||
caps.udid = '00008110-001A34303AE9801E' # ideviceinfo 取
|
||
caps.bundle_id = 'com.wohand.wohand' # 项目 SwitchBot bundleId(指南示例 com.demo.wohand 为 demo 包)
|
||
caps.automation_name = 'XCUITest'
|
||
caps.set_capability('wdaLocalPort', 8200)
|
||
driver = webdriver.Remote('http://127.0.0.1:4723', options=caps)
|
||
```
|
||
|
||
**当前 iOS 真机**:iPhone 14 Pro,iOS 26.5,UDID `00008110-001A34303AE9801E`。
|
||
|
||
**常见错误**:`Port 8100 occupied`→`pkill -f iproxy` + 用 8200;`accept trust dialog`→手机点信任;`WDA build failed`→手动 `xcodebuild` 构建 WDA(见 `iOS连接修复指南.md` 第4节);`Connection refused`→确认 WDA 已在设备运行。
|
||
|
||
注:仓库自定义 `WDADriver` 走 8100(iproxy 8100:8100);若 8100 冲突,按上面切 8200 或先 `pkill -f iproxy` 再起。
|
||
|
||
---
|
||
|
||
## DeviceDriver 可用接口
|
||
|
||
```typescript
|
||
interface DeviceDriver {
|
||
platform: 'ios' | 'android';
|
||
createSession(): Promise<void>;
|
||
destroySession(): Promise<void>;
|
||
|
||
// 元素查找
|
||
findElement(locator: ElementLocator): Promise<string | null>;
|
||
findElements(locator: ElementLocator): Promise<string[]>;
|
||
findElementRaw(using: string, value: string): Promise<string | null>;
|
||
findElementsRaw(using: string, value: string): Promise<string[]>;
|
||
getElementRect(elementId: string): Promise<{ x, y, width, height }>;
|
||
getElementAttribute(elementId: string, attr: string): Promise<string>;
|
||
|
||
// 交互操作
|
||
tap(x: number, y: number): Promise<void>;
|
||
doubleTap(x: number, y: number): Promise<void>;
|
||
longPress(x: number, y: number, duration?: number): Promise<void>;
|
||
tapElement(elementId: string): Promise<void>;
|
||
clickElement(elementId: string): Promise<void>;
|
||
typeText(elementId: string, text: string): Promise<void>;
|
||
clearText(elementId: string): Promise<void>;
|
||
swipe(fromX: number, fromY: number, toX: number, toY: number, duration?: number): Promise<void>;
|
||
scrollDown(distance?: number): Promise<void>;
|
||
scrollUp(distance?: number): Promise<void>;
|
||
|
||
// 页面操作
|
||
goBack(): Promise<void>;
|
||
getSource(): Promise<string>; // 获取当前页面XML源码
|
||
getWindowSize(): Promise<{ width, height }>;
|
||
screenshot(): Promise<string>; // base64
|
||
|
||
// 高级操作
|
||
tapByLocator(locator: ElementLocator): Promise<boolean>;
|
||
waitForElement(locator: ElementLocator, timeoutMs?: number): Promise<string | null>;
|
||
isElementVisible(locator: ElementLocator): Promise<boolean>;
|
||
goBackToHomepage(): Promise<boolean>;
|
||
dismissPopupIfPresent(): Promise<boolean>;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 元素定位器格式
|
||
|
||
```typescript
|
||
interface ElementLocator {
|
||
name: string;
|
||
ios?: { using: 'name' | 'predicate string' | 'class name'; value: string };
|
||
android?: { using: 'id' | 'text' | 'content-desc' | '-android uiautomator'; value: string };
|
||
}
|
||
```
|
||
|
||
iOS 常用定位策略:
|
||
- `predicate string`: `label == "文本"`, `label CONTAINS "部分文本"`, `type == "XCUIElementTypeButton"`
|
||
- `class name`: `XCUIElementTypeStaticText`, `XCUIElementTypeCell`, `XCUIElementTypeTextField`
|
||
- 组合: `label == "AI Events" AND type == "XCUIElementTypeStaticText"`
|
||
- `name`: 直接通过 accessibility identifier 查找
|
||
|
||
Android 常用定位策略:
|
||
- `-android uiautomator`: `new UiSelector().text("精确文本")`, `new UiSelector().textContains("部分文本")`
|
||
- `-android uiautomator`: `new UiSelector().className("android.widget.EditText").instance(0)` (按序号)
|
||
- `id`: `com.theswitchbot.switchbot:id/xxx` (资源ID)
|
||
- `text`: 精确文本匹配 (通过 mapStrategy 映射为 UiSelector().text())
|
||
- `content-desc`: 无障碍描述
|
||
|
||
Android 注意事项:
|
||
- `findByText('name', text)` 映射为 `UiSelector().text(text)` — 精确匹配
|
||
- `findByTextContains` 使用 `predicate string` → 映射为 `UiSelector().textContains(text)`
|
||
- 弹窗按钮 (如 "Restart Now", "Got it", "Delete") 需要用 `-android uiautomator` 直接定位
|
||
- Android 没有 iOS 的 accessibility label,主要用 text/id/content-desc
|
||
|
||
---
|
||
|
||
## 读取用例来源
|
||
|
||
### 通过 ONES CLI 读取用例库
|
||
|
||
```bash
|
||
# 列出用例库中指定模块的所有用例
|
||
/Users/woan/local/bin/ones testcase case list <library_uuid>
|
||
|
||
# 结果为 JSON 数组,每条用例包含:
|
||
# - name: 用例标题
|
||
# - module_uuid: 所属模块UUID
|
||
# - condition: 前置条件文本
|
||
# - steps: 操作步骤 (通常为空数组,需要从 name+condition 推断)
|
||
```
|
||
|
||
用例库/模块UUID通过ONES平台URL获取:
|
||
- `https://ones.cn/.../library/{libraryUUID}/module/{moduleUUID}`
|
||
|
||
---
|
||
|
||
## 测试脚本模板
|
||
|
||
```typescript
|
||
import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest';
|
||
import { DeviceDriver } from '../../drivers/types';
|
||
import { createDriver } from '../../drivers/factory';
|
||
import { TestReporter } from '../../utils/test-reporter';
|
||
import { getDeviceName } from '../../config/device.config';
|
||
import { sleep } from '../../utils/common';
|
||
import * as dotenv from 'dotenv';
|
||
import * as path from 'path';
|
||
|
||
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||
|
||
const DEVICE_NAME = getDeviceName('设备类型key', 'ENV_VAR_NAME');
|
||
|
||
describe('【模块名称】- 功能覆盖', () => {
|
||
let driver: DeviceDriver;
|
||
let reporter: TestReporter;
|
||
|
||
beforeAll(async () => {
|
||
driver = createDriver();
|
||
await driver.createSession();
|
||
reporter = new TestReporter('模块_子模块', driver.platform.toUpperCase());
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
try {
|
||
await driver.dismissPopupIfPresent();
|
||
await driver.goBackToHomepage();
|
||
await driver.dismissPopupIfPresent();
|
||
} catch {
|
||
try { await driver.destroySession(); } catch {}
|
||
await driver.createSession();
|
||
await sleep(3000);
|
||
}
|
||
});
|
||
|
||
afterAll(async () => {
|
||
reporter.generate();
|
||
await driver.destroySession();
|
||
});
|
||
|
||
// --- 辅助函数 ---
|
||
async function captureScreenshot(): Promise<string | undefined> {
|
||
try { return await driver.screenshot(); } catch { return undefined; }
|
||
}
|
||
|
||
async function waitForLoading(maxWait = 30000): Promise<void> {
|
||
const start = Date.now();
|
||
while (Date.now() - start < maxWait) {
|
||
const s = await driver.getSource();
|
||
if (!s.includes('Loading') && !s.includes('In progress')) return;
|
||
await sleep(3000);
|
||
}
|
||
}
|
||
|
||
// --- 测试用例 ---
|
||
it('X.X 用例标题', { timeout: 120000 }, async () => {
|
||
const start = Date.now();
|
||
try {
|
||
// 1. 前置操作 (导航到目标页面)
|
||
// 2. 执行操作步骤
|
||
// 3. 断言验证
|
||
|
||
reporter.record('用例标题', 'PASS', Date.now() - start, '验证成功描述');
|
||
} catch (e: any) {
|
||
const ss = await captureScreenshot();
|
||
reporter.record('用例标题', 'FAIL', Date.now() - start, e.message, ss);
|
||
throw e;
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## AI Hub 导航路径参考
|
||
|
||
### 进入 Hub 功能页
|
||
```
|
||
主页 → 找到并点击 AI Hub 卡片 → 等待 Loading → Hub 功能页
|
||
```
|
||
|
||
Hub 卡片定位:
|
||
```typescript
|
||
// iOS
|
||
const hubEl = await driver.findElementRaw('predicate string',
|
||
`name CONTAINS "${AIHUB_NAME}" AND type == "XCUIElementTypeCell"`);
|
||
// Android
|
||
const hubEl = await driver.findDeviceCard(AIHUB_NAME);
|
||
```
|
||
|
||
Hub 功能页特征: 包含 "Cameras", "AI Events", "Try OpenClaw"
|
||
|
||
### 进入 Hub 设置页
|
||
```
|
||
Hub 功能页 → 点击右上角齿轮 → 等待 Loading → Hub 设置列表
|
||
```
|
||
|
||
Hub 设置页特征: 包含 "Motion Detection", "Firmware", "Wi-Fi", "NFC", 摄像头名称列表
|
||
|
||
### 进入侦测设置 (Motion Detection)
|
||
```
|
||
Hub 设置页 → 点击 "Motion Detection" → 选择摄像头页 → 点击目标摄像头 → 侦测设置页
|
||
```
|
||
|
||
侦测设置页特征: 包含 "Sensitivity", "Humans", "Vehicles", "Pets", "Edit Detection Zone", "Detection Sound Alarms", "Schedules"
|
||
|
||
### 关键坐标
|
||
| 操作 | iOS (390x844) | Android (1080x2280) |
|
||
|------|--------------|---------------------|
|
||
| 返回按钮 | (39, 70) | driver.goBack() |
|
||
| 右上角设置齿轮 | (361, 70) | (999, 175) |
|
||
| 右上角日报图标 | (325, 70) | (884, 175) |
|
||
| 右上角历史记录(日报页) | - | 最右侧按钮(动态获取) |
|
||
| AICam入口(日报页右下角) | - | (953, 2073) |
|
||
|
||
### 已发现页面元素
|
||
|
||
#### Hub 功能页
|
||
```
|
||
texts: AI Hub 6C | Try OpenClaw | AI Routines | AI Events | No face detected. | Cameras | 摄像头名+时间
|
||
descs: Try OpenClaw | AI Routines | AI Events | 摄像头名+时间
|
||
```
|
||
|
||
#### Smart Report (家居日报) 页面
|
||
```
|
||
texts: AI Hub 6C | 摄像头名 | 时间 | Smart Report | 日期 | Care taking
|
||
分页圆点: y≈874, 8个 (17x17 指示器,不可点击)
|
||
切换方式: 水平 swipe (850→230, y=650, duration=0.5)
|
||
```
|
||
|
||
#### 事件详情页
|
||
```
|
||
texts: 摄像头名 | 日期时间 | Recommended Automation | Tap to learn more. | Recommended Notifications | Tap to view details. | Report false recognition | View Playback
|
||
descs: Recommended Automation, Tap to learn more. | Recommended Notifications, Tap to view details. | Report false recognition | View Playback
|
||
```
|
||
|
||
#### AI事件分析详情页 (平铺视图点击事件进入)
|
||
```
|
||
Android (1080x2280):
|
||
返回按钮: (46,141,69x69) → center (80,175)
|
||
视频预览: (0,239,1080x605)
|
||
视频右上角按钮: (980,277,63x63)
|
||
推荐自动化(View Playback右侧): (421,921,52x52) → center (447,947)
|
||
推荐通知(更右侧): (780,895,104x104) → center (832,947)
|
||
另一按钮(最右): (930,895,104x104) → center (982,947)
|
||
View Playback文字: (86,913,335x68)
|
||
底部按钮行:
|
||
删除: (659,2033,58x58) → center (688,2062)
|
||
下载: (809,2033,58x58) → center (838,2062)
|
||
分享: (959,2033,58x58) → center (988,2062)
|
||
页面指示器: (751,1918,46x46)
|
||
```
|
||
|
||
#### 误识别提交流程
|
||
```
|
||
Report false recognition → 弹窗(Please Note + Cancel + Agree) → 表单页(False Recognition + Expected description + Submit)
|
||
```
|
||
|
||
#### AI Events (AICam) 页面
|
||
```
|
||
texts: AI Events | Today | 数字 | 日期 | 时间列表 | Analysis
|
||
入口: 日报页右下角按钮(953, 2073)
|
||
```
|
||
|
||
#### AI Routines 页面 (从Hub功能页 → AI Routines入口进入)
|
||
```
|
||
texts: AI Routines | Automations | Notifications | No data. | Add
|
||
descs: Automations | Notifications | Add
|
||
入口: Hub功能页 → description("AI Routines")
|
||
```
|
||
|
||
AI Routines创建自动化流程:
|
||
```
|
||
Add → Smart Devices(设备列表) → 选摄像头 → Add condition(Detects a scenario/Detects objects)
|
||
→ Detects objects (AI Hub) → 选detection类型(Detects all/faces/human/animals/vehicles...)
|
||
→ Create Automation页面(Name/When/Add action/Save) → Add action
|
||
→ Notification(Content输入+Save) → Save
|
||
```
|
||
|
||
AI Routines删除自动化流程:
|
||
```
|
||
Automations标签 → 点击列表项(坐标540,600) → Edit Automation页面(含Delete按钮)
|
||
→ Delete → 确认弹窗(Cancel|Delete) → 确认Delete → 列表变空
|
||
```
|
||
|
||
#### 通用自动化页面 (首页底部Automations tab)
|
||
```
|
||
底部tab: descs: Home | Automations | Shop | Profile
|
||
Add按钮: resourceId("com.theswitchbot.switchbot:id/addBto")
|
||
引导弹框: 首次进入可能弹出多个"Got it"引导提示
|
||
创建页: Create Automation | Name | Enter Automation name | When | Add condition | Perform | Add action | Save
|
||
条件类型: Smart Devices | Schedules | When I Arrive | When I Leave | NFC Tag | Outdoor Weather
|
||
动作类型: Smart Devices | Notification | Activate Scene | Delay Action | Enable/Disable Automations
|
||
```
|
||
|
||
#### Hub 设置页 → 勿扰模式 (Do Not Disturb)
|
||
```
|
||
入口: Hub设置页 → 找到"Do Not Disturb" → 点击进入
|
||
DND列表页特征: 包含 "Do Not Disturb" + ("Add" 或 "Tap Add below")
|
||
DND列表页元素:
|
||
texts: Do Not Disturb | Add | 时间段描述(如"22:00-08:00, Only once")
|
||
descs: Add | 时间段描述(content-desc)
|
||
删除按钮: 右上角第二个按钮(999, 175) → 进入删除模式
|
||
删除模式: Select All | Finish | Delete | 复选框
|
||
DND编辑页特征: 包含 "Start time" + "End time" + "Save"
|
||
DND编辑页元素:
|
||
texts: Start time | End time | Only once | Repeat | Sun | Mon | Tue | Wed | Thur | Fri | Sat | Save
|
||
注意: 星期四是"Thur"不是"Thu"
|
||
注意: 重复模式文本是"Only once"不是"Once"
|
||
注意: 切换到"Repeat"时所有7天默认全选,需手动取消不要的天
|
||
退出编辑页(未保存内容弹窗):
|
||
goBack() → 弹窗出现 → 点击"Confirm"退出(Cancel不会退出!)
|
||
规则: 点Cancel无法返回上一页,必须点Confirm
|
||
```
|
||
|
||
#### Hub 设置页 → 投屏设置 (Extended Display Settings)
|
||
```
|
||
入口: Hub设置页 → 找到"Extended Display Settings" → 点击进入
|
||
页面标题: "Extended Display Settings"
|
||
三种模式(content-desc带描述):
|
||
- Standard Layout: "Arranges snapshots based on connected camera count."
|
||
- Report Layout: "Displays smart reports on the left side (AI+ service required)."
|
||
- Live: "Displays live feeds from selected camera(s)."
|
||
行为:
|
||
- 点击Standard/Report: 切换投屏布局模式
|
||
- 点击Report Layout: 如AI+未开通则弹出服务提示
|
||
- 点击Live: 立即开始实时投屏(app退到后台/桌面)
|
||
注意: Live不是设置页内的选项,是直接执行动作
|
||
```
|
||
|
||
### 导航辅助函数模式 (Android)
|
||
```typescript
|
||
// 从主页进入 Hub 功能页
|
||
async function navToHubFunction(): Promise<boolean> {
|
||
const src = await driver.getSource();
|
||
if (src.includes('Cameras') && src.includes('AI Events')) return true;
|
||
await driver.goBackToHomepage();
|
||
await sleep(2000);
|
||
const card = await driver.findDeviceCard(AIHUB_NAME);
|
||
if (!card) return false;
|
||
await driver.tapElement(card);
|
||
await sleep(5000);
|
||
return true;
|
||
}
|
||
|
||
// 从功能页进入设置页
|
||
async function navToHubSettings(): Promise<boolean> {
|
||
if (!(await navToHubFunction())) return false;
|
||
await driver.tap(965, 175); // Android 齿轮坐标
|
||
await sleep(5000);
|
||
const src = await driver.getSource();
|
||
return src.includes('Motion Detection') || src.includes('Firmware');
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 转换规则
|
||
|
||
### 1. 用例编号与分组
|
||
- Ones 中的测试用例按功能模块分组,转换时保留分组层级
|
||
- 编号格式: `X.Y 描述`,X 为功能组号,Y 为组内序号
|
||
- describe 命名: `【产品/页面名称】- 功能覆盖描述`
|
||
|
||
### 2. 前置条件 → 辅助函数
|
||
- 手工用例中的"前置条件"转为可复用的 `ensureOnXxxPage()` 辅助函数
|
||
- 辅助函数必须**智能检测当前页面状态**再决定是否导航
|
||
- 导航失败时返回 false 而非抛错(由调用方决定是 skip 还是 fail)
|
||
- 每步导航后必须 `waitForLoading()` 等待异步加载
|
||
|
||
### 3. 页面检测策略
|
||
|
||
`ensureOnXxxPage()` 必须处理以下状态:
|
||
```typescript
|
||
async function ensureOnTargetPage(): Promise<boolean> {
|
||
const source = await driver.getSource();
|
||
// 1. 已在目标页 → return true
|
||
// 2. 在目标页的子页面 (如编辑器页) → Cancel/Back 返回
|
||
// 3. 在目标页的父页面 (如设置列表) → 点击进入
|
||
// 4. 在关联页面 (如 Hub 功能页) → 进设置 → 进目标
|
||
// 5. 完全不相关 → 全量导航
|
||
}
|
||
```
|
||
|
||
### 4. 操作步骤 → 自动化操作
|
||
|
||
| 手工操作 | iOS 实现 | Android 实现 |
|
||
|---------|----------|-------------|
|
||
| 点击文字按钮 | `findElementRaw('name', 'xxx')` + `tapElement` | `findElementRaw('-android uiautomator', 'new UiSelector().text("xxx")')` + `tapElement` |
|
||
| 点击包含文字 | `predicate string` + CONTAINS | `new UiSelector().textContains("xxx")` |
|
||
| 输入文本 | `findElementRaw('class name', 'XCUIElementTypeTextField')` | `new UiSelector().className("android.widget.EditText").instance(N)` |
|
||
| 上滑/下滑 | `swipe(fromX, fromY, toX, toY, duration)` | 同左 |
|
||
| 左滑截图切换 | `swipe(300, 300, 80, 300, 0.5)` | `swipe(850, 650, 230, 650, 0.5)` |
|
||
| 右滑截图切换 | `swipe(80, 300, 300, 300, 0.5)` | `swipe(230, 650, 850, 650, 0.5)` |
|
||
| 左滑删除 | `swipe(元素X+100, 元素Y, 元素X-50, 元素Y, 0.3)` | 同左 |
|
||
| 等待页面加载 | `waitForLoading()` 或 `sleep(ms)` + `getSource()` | 同左 |
|
||
| 返回上一页 | `tap(39, 70)` | `driver.goBack()` |
|
||
| 滚动查找元素 | `scrollToAndTap(name)` | swipe + findElementRaw 循环 |
|
||
| 开关操作 | `findElementsRaw('class name', 'XCUIElementTypeSwitch')` | `new UiSelector().className("android.widget.Switch")` |
|
||
|
||
### 5. 预期结果 → 断言
|
||
- 页面包含特定文案: `expect(source).toContain('预期文案')`
|
||
- 元素可见: `expect(await driver.findElementRaw('name', 'xxx')).not.toBeNull()`
|
||
- 页面不包含: `expect(source).not.toContain('不应出现的文案')`
|
||
- 跳转验证: `getSource()` 检查目标页面特征元素
|
||
- 开关状态: `getElementAttribute(switch, 'value')` 检查 "0"/"1"
|
||
|
||
### 6. 坐标处理策略
|
||
- **优先使用元素名定位**: `findElementRaw('name', '实际元素名')` + `tapElement`
|
||
- **动态坐标**: `findElementRaw` → `getElementRect` → 计算中心点 → `tap`
|
||
- **固定坐标仅用于**: 返回按钮 (39,70)、齿轮图标 (999,175) 等系统级UI
|
||
- **不要猜测元素名**: 首次运行时用 `getSource()` 提取实际 name/label
|
||
- **swipe duration 单位是秒**: 0.3~0.5 为正常范围,不是毫秒!超过1秒会导致超时
|
||
- **分页圆点不可点击**: 仅为指示器,切换用 swipe 手势
|
||
|
||
### 7. 元素名发现工作流 (边跑边写)
|
||
|
||
首轮脚本中加入调试代码:
|
||
```typescript
|
||
// 获取当前页面所有元素名
|
||
const source = await driver.getSource();
|
||
const nameRe = /name="([^"]{1,80})"/g;
|
||
const names: string[] = [];
|
||
let m;
|
||
while ((m = nameRe.exec(source)) !== null) {
|
||
if (!names.includes(m[1])) names.push(m[1]);
|
||
}
|
||
console.log('Page elements:', names.join(' | '));
|
||
```
|
||
|
||
根据输出修正元素查找:
|
||
- 设计文档说 "Detection Zone" → 实际页面是 "Edit Detection Zone"
|
||
- 设计文档说 "Settings" → 实际页面无此元素,需用坐标 (361, 70)
|
||
- 设计文档说 "Mask" → 需要先滚动才能看到
|
||
|
||
### 8. Loading 等待策略
|
||
|
||
Hub 相关页面经常出现 "In progress" / "Loading..." 状态:
|
||
```typescript
|
||
async function waitForLoading(maxWait = 30000): Promise<void> {
|
||
const start = Date.now();
|
||
while (Date.now() - start < maxWait) {
|
||
const s = await driver.getSource();
|
||
if (!s.includes('Loading') && !s.includes('In progress')) return;
|
||
await sleep(3000);
|
||
}
|
||
}
|
||
```
|
||
|
||
每次 `tapElement` 后跟 `waitForLoading()`:
|
||
- 进入 Hub 功能页后
|
||
- 进入 Hub 设置页后
|
||
- 点击 Motion Detection 后
|
||
- 保存配置后
|
||
|
||
### 9. 稳定性策略
|
||
- 每次页面跳转后 `sleep(3000~5000)` + `waitForLoading()`
|
||
- 操作前通过 `getSource()` 确认当前页面状态
|
||
- 弹窗处理: `beforeEach` 中调用 `dismissPopupIfPresent()`
|
||
- 超时设置: 每个 it 设置 `{ timeout: 120000 }` (2分钟)
|
||
- 导航函数内加 `console.log` 标记进度
|
||
- `goBackToHomepage()` 可能失败或返回非主页状态 — 必须检查返回后的 source
|
||
|
||
### 10. 异常处理与跳过规则
|
||
- 不考虑异常功能用例(设备离线、网络断开等)
|
||
- 设备不支持某功能时用 `reporter.record(..., 'SKIP', ..., '原因描述')` + `return` 跳过
|
||
- **重要**: 跳过必须用 `'SKIP'` 状态,不要用 `'PASS'` — 跳过算PASS会导致报告通过率虚高
|
||
- **禁止**: 找不到元素就直接 skip — 必须确认是"设备不支持"而非"定位策略错误"
|
||
- 通过率计算规则: `passRate = passed / (total - skipped) * 100%`,SKIP不计入有效用例
|
||
|
||
### 11. 失败截图必须捕获
|
||
|
||
每个测试用例 catch 块必须正确捕获截图并传给 reporter:
|
||
```typescript
|
||
} catch (e: any) {
|
||
const ss = await driver.screenshot().catch(() => '');
|
||
reporter.record('用例名', 'FAIL', Date.now() - start, e.message, ss);
|
||
throw e;
|
||
}
|
||
```
|
||
|
||
**常见错误**:
|
||
- ❌ `await screenshot('label');` — 没有赋值,reporter 拿不到截图
|
||
- ❌ `reporter.record(..., 'FAIL', ..., e.message);` — 缺少第5个参数 ss
|
||
- ✓ `const ss = await driver.screenshot().catch(() => '');` + `reporter.record(..., 'FAIL', ..., e.message, ss);`
|
||
|
||
### 12. AI Hub 设置页实际入口名称
|
||
|
||
以下是已验证的 AI Hub 设置页元素名称(以实际 getSource() 为准):
|
||
|
||
| 功能 | 正确入口名称 | ~~错误名称~~ |
|
||
|------|-------------|-------------|
|
||
| 固件升级 | `Firmware Update` | ~~Firmware & Battery~~ |
|
||
| 网络设置 | `Network Settings` | ~~Wi-Fi~~ |
|
||
| 勿扰模式 | `Do Not Disturb` | — |
|
||
| 投屏 | `Extended Display Settings` | — |
|
||
| 侦测 | `Motion Detection` | — |
|
||
| 云服务 | `Cloud Service` | — |
|
||
|
||
**不存在的页面**: AI Hub 没有独立的"操作日志"(Device Logs) 页面,不要为此编写测试用例。
|
||
|
||
---
|
||
|
||
## 输入格式
|
||
|
||
### 通过 ONES CLI 获取:
|
||
```bash
|
||
/Users/woan/local/bin/ones testcase case list <library_uuid>
|
||
```
|
||
|
||
用例数据字段:
|
||
- `name`: 用例标题
|
||
- `condition`: 前置条件 (如 "已绑定ptc plus 2k")
|
||
- `module_uuid`: 模块归属 (用于分组)
|
||
|
||
### 或手动提供:
|
||
```
|
||
【模块名】: XXX
|
||
【子模块】: XXX
|
||
【用例标题】: XXX
|
||
【前置条件】: XXX
|
||
【操作步骤】: (如有)
|
||
【预期结果】: (如有)
|
||
```
|
||
|
||
---
|
||
|
||
## 输出要求
|
||
|
||
1. 生成完整可执行的 `.test.ts` 文件
|
||
2. 包含所有必要的 import 和初始化
|
||
3. 包含 `ensureOnXxxPage()` 等导航辅助函数
|
||
4. 包含 `waitForLoading()` 等待函数
|
||
5. 包含 `scrollToAndTap()` 滚动查找函数
|
||
6. 每个测试用例有独立的 `it` 块,带 `{ timeout }` 和完整 try/catch + reporter.record
|
||
7. 文件命名: `tests/[模块]/[模块]_[子模块].test.ts`
|
||
8. 首轮生成时保留 `console.log` 调试输出,全部通过后移除
|
||
|
||
---
|
||
|
||
## 迭代调试流程
|
||
|
||
```
|
||
1. 读取 ONES 用例 → 生成初始脚本 (元素名基于猜测)
|
||
2. 运行脚本 → 获取失败信息和 console 输出
|
||
3. 从 getSource() 输出提取真实元素名
|
||
4. 修正脚本中的元素查找策略
|
||
5. 重复 2-4 直到全部 PASS
|
||
6. 移除调试日志,确认最终通过
|
||
```
|
||
|
||
关键: 不要在第1步就追求完美 — 先跑通再优化。
|
||
|
||
---
|
||
|
||
## 执行规则
|
||
|
||
### 1. 仅运行失败/修改用例
|
||
|
||
修改代码后**只执行相关的失败用例**,不要全量运行:
|
||
|
||
```bash
|
||
# 只跑特定用例 (用 -t 正则匹配)
|
||
PLATFORM=android npx vitest run tests/aihub/xxx.test.ts -t "4\.3|4\.4|4\.5"
|
||
|
||
# 全量验证仅在以下情况:
|
||
# - 所有失败用例都修复后的最终确认
|
||
# - 用户明确要求全量运行
|
||
```
|
||
|
||
### 2. 用例结果校验要求
|
||
|
||
- **所有用例必须有严格的 expect() 断言**
|
||
- 找不到元素/按钮不触发响应 → FAIL (不是 skip 或软通过)
|
||
- 验证失败就 throw error,不要 return/continue
|
||
|
||
### 3. 测试报告必须包含过程
|
||
|
||
每个测试用例使用 `steps: string[]` 记录执行过程:
|
||
```typescript
|
||
const steps: string[] = [];
|
||
steps.push('确认在AI Events页面');
|
||
// ... 每个操作步骤都记录
|
||
reporter.record('用例名', 'PASS', duration, steps.join(' → '));
|
||
```
|
||
|
||
### 4. 删除操作验证
|
||
|
||
删除确认弹窗出现后,**点击确认(Confirm)** 执行真实删除,验证删除成功后返回列表。不要点击取消。
|
||
|
||
### 5. 视图切换注意事项
|
||
|
||
`switchToTileView()` 是**切换操作(toggle)**:
|
||
- 如果已在平铺视图,再调用会切回列表视图
|
||
- 正确做法: 先尝试点击事件卡片,判断进入的是回放页(列表视图)还是分析页(平铺视图)
|
||
- 如果进入回放页 → 返回 → 切换视图 → 重新点击
|
||
|
||
### 6. 键盘搜索
|
||
|
||
搜索功能输入关键字后,必须**按下键盘搜索按钮**才能触发搜索:
|
||
```typescript
|
||
pressKeyboardSearch(); // execSync('adb shell input keyevent 66')
|
||
```
|
||
|
||
### 7. 视频录制按钮
|
||
|
||
点击录制按钮开始录制后,必须**再次点击录制按钮**来结束录制。
|
||
|
||
### 8. 未保存内容弹窗退出
|
||
|
||
当编辑页有修改时,goBack() 会触发"未保存内容"确认弹窗:
|
||
- **点击 Cancel 不会退出**,仍停留在编辑页
|
||
- 必须**点击 Confirm** 才能真正退出回到上一页
|
||
- 规则: goBack() → 等待1.5s → 查找"Confirm"按钮 → tapElement → 等待2s
|
||
```typescript
|
||
async function exitEditPage(): Promise<void> {
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
const curSrc = await driver.getSource();
|
||
if (!curSrc.includes('编辑页特征元素')) return; // 已不在编辑页
|
||
await driver.goBack();
|
||
await sleep(1500);
|
||
const confirmEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Confirm")');
|
||
if (confirmEl) { await driver.tapElement(confirmEl); await sleep(2000); return; }
|
||
}
|
||
}
|
||
```
|
||
|
||
### 9. 模式/选项可用性判断
|
||
|
||
某些功能选项需要特定前置条件(如 AI+服务已开通),不满足时:
|
||
- 选项可能变灰(disabled)或显示提示文字
|
||
- 点击无响应或跳转到服务开通页
|
||
- 处理方式: 检测点击后页面变化,如果跳到服务开通页则判定为"当前不满足条件",记录skip并return
|
||
|
||
---
|
||
|
||
## 示例: AI Hub 侦测设置
|
||
|
||
### 导航辅助函数:
|
||
```typescript
|
||
const AIHUB_NAME = getDeviceName('aihub', 'AIHUB_NAME');
|
||
|
||
async function enterHubFunctionPage(): Promise<boolean> {
|
||
await driver.goBackToHomepage();
|
||
await sleep(1000);
|
||
await driver.dismissPopupIfPresent();
|
||
|
||
let hubEl: string | null = null;
|
||
for (let scroll = 0; scroll <= 5; scroll++) {
|
||
hubEl = await driver.findElementRaw('predicate string',
|
||
`name CONTAINS "${AIHUB_NAME}" AND type == "XCUIElementTypeCell"`);
|
||
if (!hubEl) {
|
||
hubEl = await driver.findElementRaw('predicate string', `label CONTAINS "${AIHUB_NAME}"`);
|
||
}
|
||
if (hubEl) break;
|
||
if (scroll < 5) {
|
||
await driver.swipe(195, 650, 195, 300, 500);
|
||
await sleep(1500);
|
||
}
|
||
}
|
||
if (!hubEl) return false;
|
||
|
||
await driver.tapElement(hubEl);
|
||
await waitForLoading();
|
||
await driver.dismissPopupIfPresent();
|
||
|
||
const source = await driver.getSource();
|
||
return source.includes('Cameras') || source.includes('AI Events');
|
||
}
|
||
|
||
async function enterHubSettings(): Promise<boolean> {
|
||
await driver.tap(361, 70); // 齿轮图标
|
||
await sleep(3000);
|
||
await waitForLoading();
|
||
const source = await driver.getSource();
|
||
return source.includes('Motion Detection') || source.includes('Firmware');
|
||
}
|
||
|
||
async function enterMotionDetection(): Promise<boolean> {
|
||
// 已在侦测页
|
||
const pre = await driver.getSource();
|
||
if (pre.includes('Sensitivity') || pre.includes('Edit Detection Zone')) return true;
|
||
|
||
// 在 Hub 设置页
|
||
if (pre.includes('Firmware') || pre.includes('Wi-Fi')) {
|
||
await scrollToAndTap('Motion Detection');
|
||
await sleep(3000);
|
||
await waitForLoading();
|
||
// 选择摄像头页 → 点击目标摄像头
|
||
const camEl = await driver.findElementRaw('predicate string',
|
||
`name CONTAINS "${CAMERA_NAME}"`);
|
||
if (camEl) {
|
||
await driver.tapElement(camEl);
|
||
await sleep(3000);
|
||
await waitForLoading();
|
||
}
|
||
const check = await driver.getSource();
|
||
if (check.includes('Sensitivity') || check.includes('Edit Detection Zone')) return true;
|
||
}
|
||
|
||
// 全量导航
|
||
const inHub = await enterHubFunctionPage();
|
||
if (!inHub) return false;
|
||
const inSettings = await enterHubSettings();
|
||
if (!inSettings) return false;
|
||
await scrollToAndTap('Motion Detection');
|
||
await sleep(3000);
|
||
await waitForLoading();
|
||
// 选择摄像头
|
||
const camEl = await driver.findElementRaw('predicate string',
|
||
`name CONTAINS "${CAMERA_NAME}"`);
|
||
if (camEl) {
|
||
await driver.tapElement(camEl);
|
||
await sleep(3000);
|
||
await waitForLoading();
|
||
}
|
||
const check = await driver.getSource();
|
||
return check.includes('Sensitivity') || check.includes('Edit Detection Zone');
|
||
}
|
||
```
|
||
|
||
### 测试用例:
|
||
```typescript
|
||
it('2.1 区域设置页面显示', { timeout: 90000 }, async () => {
|
||
const start = Date.now();
|
||
try {
|
||
const onPage = await ensureOnMotionDetectionPage();
|
||
expect(onPage).toBe(true);
|
||
|
||
// 点击 Edit Detection Zone (不是 "Detection Zone")
|
||
const zoneEl = await driver.findElementRaw('name', 'Edit Detection Zone');
|
||
if (!zoneEl) {
|
||
reporter.record('区域设置页面显示', 'SKIP', Date.now() - start, '设备无此选项');
|
||
return;
|
||
}
|
||
await driver.tapElement(zoneEl);
|
||
await sleep(3000);
|
||
await waitForLoading();
|
||
|
||
const source = await driver.getSource();
|
||
const hasEditor = source.includes('Save') || source.includes('Cancel') || source.includes('Drag');
|
||
expect(hasEditor).toBe(true);
|
||
|
||
// 退出编辑器
|
||
const cancelEl = await driver.findElementRaw('name', 'Cancel');
|
||
if (cancelEl) await driver.tapElement(cancelEl);
|
||
await sleep(2000);
|
||
|
||
reporter.record('区域设置页面显示', 'PASS', Date.now() - start, '区域编辑页正常');
|
||
} catch (e: any) {
|
||
const ss = await captureScreenshot();
|
||
reporter.record('区域设置页面显示', 'FAIL', Date.now() - start, e.message, ss);
|
||
throw e;
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 注意事项
|
||
|
||
- 不生成固件升级相关用例
|
||
- 不生成异常状态/边界条件用例(设备离线、网络断开等)
|
||
- 中文 label 和英文 label 都要兼容(用 OR 连接或 CONTAINS)
|
||
- 所有 sleep 时间基于真实设备实测,页面跳转至少 3 秒
|
||
- 每个 it 之间通过 `ensureOnXxxPage()` 恢复到正确页面
|
||
- Hub 页面 Loading 较慢(5-30秒),必须用 waitForLoading()
|
||
- `goBackToHomepage()` 不保证100%成功 — 必须检测返回后状态
|
||
- 元素名称以运行时 `getSource()` 获取的为准,不要猜测
|
||
|
||
---
|
||
|
||
## ONES 测试计划集成(实验性)
|
||
|
||
### 工作流概述
|
||
|
||
```
|
||
ONES测试计划 → 读取用例列表 → 转换为自动化脚本 → 执行 → 结果反写ONES
|
||
```
|
||
|
||
### 1. 从测试计划读取用例 (已验证可行)
|
||
|
||
```bash
|
||
# 查询测试计划列表
|
||
/Users/woan/local/bin/ones graphql '{ testcasePlans(limit: 10) { uuid name } }'
|
||
|
||
# 查询计划中的用例及结果
|
||
/Users/woan/local/bin/ones graphql '{ testcasePlanCases(filter: { testcasePlan: { uuid_in: ["PLAN_UUID"] } }, limit: 100) { key result executor { name } note testcaseCase { uuid name number } } }'
|
||
```
|
||
|
||
返回数据格式:
|
||
```json
|
||
{
|
||
"key": "testcase_plan_case-{planUUID}-{caseUUID}",
|
||
"result": "to_do|passed|failed|skipped",
|
||
"testcaseCase": { "uuid": "xxx", "name": "用例标题", "number": 12345 }
|
||
}
|
||
```
|
||
|
||
### 2. 结果状态映射
|
||
|
||
| 自动化 reporter 状态 | ONES 测试计划状态 |
|
||
|---------------------|-------------------|
|
||
| `'PASS'` | `passed` |
|
||
| `'FAIL'` | `failed` |
|
||
| `'SKIP'` | `skipped` |
|
||
| 未执行 | `to_do` |
|
||
|
||
### 3. 结果反写 (正确方法:GraphQL mutation,已验证)
|
||
|
||
> ⚠️ **必须走 `ones graphql` mutation,不要用 curl 直连 REST `.../cases/update`。**
|
||
> `ones config show` 把 token 打码成 `***`,curl 直连必然 `401 AuthFailure.InvalidToken`;
|
||
> `ones graphql` 复用 CLI 登录认证、无需 token/PAT,已在权限白名单。`utils/ones-sync.ts` 的 `postPayloads` 已按此实现。
|
||
|
||
**key 拼接(确定式):**
|
||
- case: `testcase_plan_case-<planUUID>-<caseUUID>`
|
||
- step: `testcase_plan_case_step-<planUUID>-<caseUUID>-<stepUuid>`
|
||
|
||
**写入:**
|
||
```bash
|
||
# case 级结果
|
||
ones graphql 'mutation { updateTestcasePlanCase(key: "testcase_plan_case-<plan>-<caseUUID>", result: "passed") { key } }'
|
||
# step 级结果(字段名 step_result,可带 actual_result)
|
||
ones graphql 'mutation { updateTestcasePlanCaseStep(key: "testcase_plan_case_step-<plan>-<caseUUID>-<stepUuid>", step_result: "passed", actual_result: "...") { key } }'
|
||
```
|
||
取值 `passed|failed|skipped|to_do`(PASS→passed、FAIL→failed、SKIP→skipped)。
|
||
|
||
**读回校验:**
|
||
```bash
|
||
ones graphql '{ testcasePlanCaseSteps(filter: { testcasePlan: { uuid_in: ["<plan>"] }, testcaseCase: { uuid_in: ["<caseUUID>"] } }, limit: 60) { key stepResult actualResult } }'
|
||
```
|
||
(查询字段驼峰 `stepResult`/`actualResult`;filter 用 `testcasePlan`/`testcaseCase` 嵌套 `uuid_in`。)
|
||
|
||
可用 mutation:`updateTestcasePlanCase` / `updateTestcasePlanCaseStep`(经 `ones graphql '{ __schema { mutationType { fields { name } } } }'` 可查全部写操作)。
|
||
|
||
### 4. 完整同步命令
|
||
|
||
```bash
|
||
# 执行自动化测试 (结果保存到 reports/.results.json)
|
||
PLATFORM=android npx vitest run tests/aihub/
|
||
|
||
# 同步结果到 ONES 测试计划 (预览)
|
||
npx ts-node scripts/sync-ones-results.ts --plan <PLAN_UUID> --dry-run
|
||
|
||
# 确认后实际写入
|
||
npx ts-node scripts/sync-ones-results.ts --plan <PLAN_UUID>
|
||
```
|
||
|
||
### 5. 集成模块
|
||
|
||
已实现文件:
|
||
- `utils/ones-sync.ts` — 核心同步逻辑(读取/匹配/反写)
|
||
- `scripts/sync-ones-results.ts` — 命令行同步脚本
|
||
|
||
关键函数:
|
||
```typescript
|
||
import { fullSync } from '../utils/ones-sync';
|
||
import { TestResult } from '../utils/test-reporter';
|
||
|
||
// 一键同步
|
||
const result = fullSync('PLAN_UUID', testResults);
|
||
// => { total: 100, matched: 15, synced: 15, failed: 0 }
|
||
```
|
||
|
||
匹配策略: 按用例名称的 LCS (最长公共子序列) 相似度匹配,阈值 0.5。
|
||
- 完全包含关系: score = 0.9
|
||
- 完全相同: score = 1.0
|
||
- LCS ratio: score = 2*lcs / (len_a + len_b)
|