AI_UIAutomation/prompts/ones_to_automation.md

32 KiB
Raw Blame History

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 可用接口

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>;
}

元素定位器格式

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 读取用例库

# 列出用例库中指定模块的所有用例
/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}

测试脚本模板

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 卡片定位:

// 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)

// 从主页进入 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() 必须处理以下状态:

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
  • 动态坐标: findElementRawgetElementRect → 计算中心点 → tap
  • 固定坐标仅用于: 返回按钮 (39,70)、齿轮图标 (999,175) 等系统级UI
  • 不要猜测元素名: 首次运行时用 getSource() 提取实际 name/label
  • swipe duration 单位是秒: 0.3~0.5 为正常范围不是毫秒超过1秒会导致超时
  • 分页圆点不可点击: 仅为指示器,切换用 swipe 手势

7. 元素名发现工作流 (边跑边写)

首轮脚本中加入调试代码:

// 获取当前页面所有元素名
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..." 状态:

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:

} 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 获取:

/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. 仅运行失败/修改用例

修改代码后只执行相关的失败用例,不要全量运行:

# 只跑特定用例 (用 -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[] 记录执行过程:

const steps: string[] = [];
steps.push('确认在AI Events页面');
// ... 每个操作步骤都记录
reporter.record('用例名', 'PASS', duration, steps.join(' → '));

4. 删除操作验证

删除确认弹窗出现后,点击确认(Confirm) 执行真实删除,验证删除成功后返回列表。不要点击取消。

5. 视图切换注意事项

switchToTileView()切换操作(toggle):

  • 如果已在平铺视图,再调用会切回列表视图
  • 正确做法: 先尝试点击事件卡片,判断进入的是回放页(列表视图)还是分析页(平铺视图)
  • 如果进入回放页 → 返回 → 切换视图 → 重新点击

6. 键盘搜索

搜索功能输入关键字后,必须按下键盘搜索按钮才能触发搜索:

pressKeyboardSearch(); // execSync('adb shell input keyevent 66')

7. 视频录制按钮

点击录制按钮开始录制后,必须再次点击录制按钮来结束录制。

8. 未保存内容弹窗退出

当编辑页有修改时goBack() 会触发"未保存内容"确认弹窗:

  • 点击 Cancel 不会退出,仍停留在编辑页
  • 必须点击 Confirm 才能真正退出回到上一页
  • 规则: goBack() → 等待1.5s → 查找"Confirm"按钮 → tapElement → 等待2s
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 侦测设置

导航辅助函数:

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');
}

测试用例:

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. 从测试计划读取用例 (已验证可行)

# 查询测试计划列表
/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 } } }'

返回数据格式:

{
  "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 数组包裹):

{
  "cases": [
    {
      "uuid": "用例UUID (testcaseCase.uuid)",
      "executor": "执行人UUID (user_id)",
      "note": "",
      "result": "passed|failed|skipped|to_do",
      "steps": []
    }
  ]
}

响应:

{
  "success_cases": ["BZfZGRcF"],
  "not_found_cases": [],
  "no_permission_cases": [],
  "not_handle_cases": []
}

说明:

  • uuid: 测试用例的 UUID不是 plan_case 的 keytestcaseCase.uuid
  • executor: 执行人 UUIDones config showuser_id 获取
  • steps: 步骤结果数组,无步骤时传空数组 []
  • 支持批量提交多条

4. 完整同步命令

# 执行自动化测试 (结果保存到 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 — 命令行同步脚本

关键函数:

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)