AI_UIAutomation/prompts/ones_to_automation.md

914 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 390x844ptUSB连接通过 iproxy 8100:8100 端口转发
- **Android 设备**: Samsung 1080x2280pxUSB连接Appium port 4723
- **App**: SwitchBot (iOS bundleId: com.wohand.wohand / Android package: com.theswitchbot.switchbot)
- **当前测试平台**: Android (通过 PLATFORM=android 环境变量切换)
---
## 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. 结果反写 (已确认可行)
API 端点:
```
POST /project/api/project/team/{team_uuid}/testcase/plan/{plan_uuid}/cases/update
```
请求体 (JSON 对象cases 数组包裹):
```json
{
"cases": [
{
"uuid": "用例UUID (testcaseCase.uuid)",
"executor": "执行人UUID (user_id)",
"note": "",
"result": "passed|failed|skipped|to_do",
"steps": []
}
]
}
```
响应:
```json
{
"success_cases": ["BZfZGRcF"],
"not_found_cases": [],
"no_permission_cases": [],
"not_handle_cases": []
}
```
说明:
- `uuid`: 测试用例的 UUID不是 plan_case 的 key`testcaseCase.uuid`
- `executor`: 执行人 UUID`ones config show``user_id` 获取
- `steps`: 步骤结果数组,无步骤时传空数组 `[]`
- 支持批量提交多条
### 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)