feat: 串口配对添加链路 + 多设备连接用例 + soak/继电器工具

- 设备添加: device-add.helper 串口配对主链路(addDeviceWithSerialPairing),
  按平台设备名注册表(device-registry.helper),hub/curtain/qr 专用 helper
- 新增多设备连接用例: remote、outdoor_meter、rn_plugin,刷新各品类 connect 用例
- 配置: relay.config(继电器通道/按键映射)、wifi.config(分平台配网凭据)
- 脚本: 夜间 soak(android/ios)、reset-devices、relay 控制与报告、调试脚本
- 工具: serial_controller 串口协议、zendesk/feedback/ones-anchor helper、test-reporter
- 杂项: 撤出追踪 .DS_Store / .claude 本地设置;.gitignore 忽略
  docs/ test-plan/ test_must_case/(生成产物/单独维护)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
woan 2026-06-09 14:14:37 +08:00
parent 373a8bd6fa
commit d5c81826fb
70 changed files with 3514 additions and 1656 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1,49 +0,0 @@
{
"permissions": {
"allow": [
"Bash(curl -s http://localhost:8100/status)",
"Bash(python3 -m json.tool)",
"Bash(xcrun simctl *)",
"Bash(idevice_id -l)",
"Bash(system_profiler SPUSBDataType)",
"Bash(xcodebuild -version)",
"Bash(tidevice list *)",
"Bash(curl -s -X POST http://localhost:8100/session -H 'Content-Type: application/json' -d '{\"capabilities\":{\"alwaysMatch\":{\"platformName\":\"iOS\",\"shouldUseSingletonTestManager\":false}}}')",
"Bash(python3 -c ' *)",
"Bash(curl -s -X POST http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/elements -H 'Content-Type: application/json' -d '{\"using\":\"predicate string\",\"value\":\"name CONTAINS \\\\\"摄像机Plus 3K\\\\\" AND type == \\\\\"XCUIElementTypeCell\\\\\"\"}')",
"Bash(curl -s http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/source)",
"Bash(curl -s -X POST http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/element/87000000-0000-0000-430D-000000000000/click -H 'Content-Type: application/json' -d '{}')",
"Bash(curl -s -X POST http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/wda/tap -H 'Content-Type: application/json' -d '{\"x\":195,\"y\":280}')",
"Bash(curl -s -X POST http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/elements -H 'Content-Type: application/json' -d '{\"using\":\"name\",\"value\":\"Features\"}')",
"Bash(curl -s -X POST http://localhost:8100/session/E0B85237-9A02-4880-AC53-249942845E57/wda/tap -H 'Content-Type: application/json' -d '{\"x\":195,\"y\":200}')",
"Bash(curl -s http://localhost:8100/session/E0B85237-9A02-4880-AC53-249942845E57/screenshot)",
"Bash(curl -s -X DELETE \"http://localhost:8100/session/E0B85237-9A02-4880-AC53-249942845E57\")",
"Bash(curl -s -X GET http://localhost:4723/sessions)",
"Bash(curl -s http://localhost:8100/session/C0178F7D-492D-4E16-9E50-A6B9AFC34937/source)",
"Bash(curl -s http://localhost:8100/session/__TRACKED_VAR__/source)",
"Bash(curl -s -X POST http://localhost:8100/session/FF243246-C365-4F90-8590-E42A5D724D45/element -H 'Content-Type: application/json' -d '{\"using\":\"name\",\"value\":\"More\"}')",
"Bash(curl -s -X GET https://sz.ones.cn/openapi/v1/team/98Q19ZsW/testcase/plans -H 'Content-Type: application/json' -H 'Ones-Auth-Token: CN-MGM2MmM4NmFjYjU0NDliZDU2MjlhN2YyZTEwM2ViMWM-pat_f8dc5cd57c9f450975d7c0e6147e9fd5' -w '\\\\nHTTP_CODE: %{http_code}\\\\n')",
"WebFetch(domain:developer.ones.cn)",
"Bash(curl -s 'https://sz.ones.cn/openapi/v2/testcase/libraries/cases?teamID=98Q19ZsW&testCaseLibraryID=HFcbrnoL' -H 'Authorization: Bearer CN-MjFiZTI1YmUwNWQ1NGYyZjU4YzMzZmNlNDEwNGY2OGQ-pat_914ebfdbd0ad400c6e7f610093b3d1e4')",
"Bash(curl -s 'https://sz.ones.cn/openapi/v2/testcase/libraries/cases?teamID=98Q19ZsW&testCaseLibraryID=8WUGnd2a' -H 'Authorization: Bearer CN-MjFiZTI1YmUwNWQ1NGYyZjU4YzMzZmNlNDEwNGY2OGQ-pat_914ebfdbd0ad400c6e7f610093b3d1e4')",
"Bash(curl -s 'https://sz.ones.cn/openapi/v2/testcase/libraries/cases?teamID=98Q19ZsW&library=HFcbrnoL' -H 'Authorization: Bearer CN-MjFiZTI1YmUwNWQ1NGYyZjU4YzMzZmNlNDEwNGY2OGQ-pat_914ebfdbd0ad400c6e7f610093b3d1e4')",
"Bash(curl -s 'https://sz.ones.cn/openapi/v2/testcase/libraries?teamID=98Q19ZsW&pageSize=50&cursor=8M5Xs9OUjhIxAAAAdGVzdGNhc2VfbGlicmFyeS1MRHJKbVZ5Sw==' -H 'Authorization: Bearer CN-MjFiZTI1YmUwNWQ1NGYyZjU4YzMzZmNlNDEwNGY2OGQ-pat_914ebfdbd0ad400c6e7f610093b3d1e4')",
"Bash(chmod +x /Users/woan/local/bin/ones)",
"Bash(/Users/woan/local/bin/ones config *)",
"Bash(/Users/woan/local/bin/ones graphql *)",
"mcp__figma__get_screenshot",
"Bash(/Users/woan/.local/bin/ones config *)",
"Bash(/Users/woan/local/bin/ones testcase *)",
"Bash(npx tsc *)",
"Bash(npx vitest *)",
"Bash(appium --version)",
"Bash(appium --port 4723 --allow-insecure chromedriver_autodownload)",
"Bash(echo \"Appium PID: $!\")",
"Bash(curl -s http://localhost:4723/status)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('Appium ready:', d.get\\('value',{}\\).get\\('ready',False\\)\\)\")",
"Bash(adb devices *)",
"Bash(xcrun xctrace *)",
"Bash(PLATFORM=android npx vitest run tests/aihub/aihub_daily_report.test.ts)"
]
}
}

7
.gitignore vendored
View File

@ -6,3 +6,10 @@ midscene_run/
.idea/
.DS_Store
.claude/
# 旧 Appium/pytest 必测用例 + allure 报告产物(不入库)
test_must_case/
# 测试计划/清单(本地生成,机器相关)
test-plan/
# 文档(单独维护,不随代码提交)
docs/

View File

@ -1,3 +1,5 @@
import { getAddedDevice } from '../utils/common/device-registry.helper';
export interface DeviceCategoryConfig {
devices: string[];
defaultDevice: string;
@ -9,11 +11,11 @@ export const DEVICE_CONFIG: Record<string, DeviceCategoryConfig> = {
defaultDevice: 'Bot 0F',
},
camera: {
devices: ['摄像机Plus 3K'],
devices: ['摄像机Plus 3K', 'Pan Tilt', 'Pan Tilt Cam 2K', 'Pan Tilt Plus 2K', 'Pan Tilt Plus 3K', 'Indoor Cam'],
defaultDevice: '摄像机Plus 3K',
},
osc: {
devices: ['OSC 2K'],
devices: ['OSC 2K', 'Outdoor'],
defaultDevice: 'OSC 2K',
},
curtain: {
@ -21,7 +23,7 @@ export const DEVICE_CONFIG: Record<string, DeviceCategoryConfig> = {
defaultDevice: 'Curtain 1A',
},
plug: {
devices: ['Plug 4D', 'PlugMini 5E'],
devices: ['Plug 4D', 'PlugMini 5E', 'Plug Mini (JP)'],
defaultDevice: 'Plug 4D',
},
lock: {
@ -29,7 +31,7 @@ export const DEVICE_CONFIG: Record<string, DeviceCategoryConfig> = {
defaultDevice: 'Lock 6F',
},
hub: {
devices: ['Hub2 8H', 'HubMini 9I', 'HubMiniMatter 0J'],
devices: ['Hub2 8H', 'HubMini 9I', 'HubMiniMatter 0J', 'Hub Plus'],
defaultDevice: 'Hub2 8H',
},
humidifier: {
@ -37,7 +39,7 @@ export const DEVICE_CONFIG: Record<string, DeviceCategoryConfig> = {
defaultDevice: 'Humidifier 1K',
},
meter: {
devices: ['Meter 3M', 'Outdoor Meter 4N'],
devices: ['Meter 3M', 'Meter Plus', 'Outdoor Meter 4N'],
defaultDevice: 'Meter 3M',
},
sensor: {
@ -45,7 +47,7 @@ export const DEVICE_CONFIG: Record<string, DeviceCategoryConfig> = {
defaultDevice: 'Contact Sensor 5O',
},
ceilingLight: {
devices: ['CeilingLight 7Q'],
devices: ['CeilingLight 7Q', 'Ceiling Light pro'],
defaultDevice: 'CeilingLight 7Q',
},
colorBulb: {
@ -65,7 +67,7 @@ export const DEVICE_CONFIG: Record<string, DeviceCategoryConfig> = {
defaultDevice: 'WaterDetector 1U',
},
keypad: {
devices: ['Keypad 2V'],
devices: ['Keypad 2V', 'Keypad Touch'],
defaultDevice: 'Keypad 2V',
},
remote: {
@ -99,6 +101,21 @@ export const DEVICE_CONFIG: Record<string, DeviceCategoryConfig> = {
};
export function getDeviceName(category: string, envVar?: string): string {
if (envVar && process.env[envVar]) {
return process.env[envVar]!;
}
// 添加成功后写入的实际设备名(进入功能页入口),优先于配置默认值
const added = getAddedDevice(category);
if (added) return added;
return DEVICE_CONFIG[category]?.defaultDevice || category;
}
/**
* ( ONES "按型号"):env >
* ****( "Meter 0B"),( "Meter 3M"),
* 两者需区分:操作入口用 getDeviceName(),/ getModelName()
*/
export function getModelName(category: string, envVar?: string): string {
if (envVar && process.env[envVar]) {
return process.env[envVar]!;
}

108
config/relay.config.ts Normal file
View File

@ -0,0 +1,108 @@
/**
* + (,/)
*
*
* ,( 'remote' 'pairing')
*
* ****:Android iOS ,
* { android, ios };(/) process.env.PLATFORM
* ( config/app.config, ios)
*
* 线(Android,2026-06):ch33=meter ch34=remote ch35=remote
* iOS 线( iOS )
* env 覆盖:RELAY_HOLD_MS_<DEVICE>_<ACTION>()
*/
/** 通道号:两端相同写数字;两端不同写 { android, ios }。 */
export type ChannelSpec = number | { android?: number; ios?: number };
export interface RelayAction {
buttons: string[]; // 同时按下的按键名(引用本设备 buttons 的 key);单键即数组里一个
holdMs: number; // 按住毫秒数
desc?: string;
}
export interface RelayDeviceConfig {
buttons: Record<string, ChannelSpec>; // 按键名 → 通道(可按平台)
actions?: Record<string, RelayAction>;
}
export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
meter: {
buttons: { main: { android: 33 /*, ios: <填iOS通道> */ } },
actions: {
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键进入添加模式' },
},
},
remote: {
buttons: {
convex: { android: 34 /*, ios: <填> */ }, // 凸键
concave: { android: 35 /*, ios: <填> */ }, // 凹键
},
actions: {
pairing: { buttons: ['convex', 'concave'], holdMs: 2000, desc: '同时按凸键+凹键 2s 进入添加模式(切换式:按一次进、再按退,勿重按)' },
},
},
outdoor_meter: {
buttons: { main: { android: 36 /*, ios: <填> */ } },
actions: {
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式' },
},
},
curtain3: {
buttons: {
power: { android: 37 /*, ios: <填> */ }, // 开关按钮
track: { android: 38 /*, ios: <填> */ }, // 控制轨道按钮(控制用,不参与配对)
},
actions: {
pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关按钮 2s 进入添加模式' },
},
},
curtain: {
buttons: { power: { android: 39 /*, ios: <填> */ } }, // 开关按钮
actions: {
pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关按钮 2s 进入添加模式' },
},
},
hub: {
buttons: { main: { android: 43 /*, ios: <填> */ } }, // Hub Mini 主键
actions: {
pairing: { buttons: ['main'], holdMs: 4000, desc: '按住主键 4s 进入添加模式(Hub Mini;指引页写3s,实测需4s)' },
},
},
};
function currentPlatform(): 'android' | 'ios' {
return (process.env.PLATFORM || 'ios').toLowerCase() === 'android' ? 'android' : 'ios';
}
/** 解析某按键在当前平台的通道号。iOS 未单独配通道时,回退到 android(两端复用同一套设备/通道时用)。 */
function channelOf(device: string, button: string): number {
const spec = RELAY_MAP[device]?.buttons?.[button];
if (spec == null) throw new Error(`relay 配置无 '${device}.${button}'(config/relay.config.ts)`);
if (typeof spec === 'number') return spec;
const plat = currentPlatform();
const ch = spec[plat] ?? spec.android; // iOS 缺省回退 android(先复用 Android 配置)
if (ch == null) throw new Error(`'${device}.${button}' 未配置 ${plat}(且无 android 回退)的通道(config/relay.config.ts)`);
return ch;
}
/** 取设备某动作要触发的通道列表 + 时长(按当前平台解析通道;env 可覆盖时长)。 */
export function resolveRelayAction(
device: string,
action: string
): { channels: number[]; holdMs: number; desc?: string } {
const dev = RELAY_MAP[device];
if (!dev) throw new Error(`relay 配置无设备 '${device}'(config/relay.config.ts)`);
const act = dev.actions?.[action];
if (!act) throw new Error(`设备 '${device}' 无动作 '${action}'`);
const channels = act.buttons.map((b) => channelOf(device, b));
const envKey = `RELAY_HOLD_MS_${device.toUpperCase()}_${action.toUpperCase()}`;
const holdMs = process.env[envKey] ? parseInt(process.env[envKey] as string, 10) : act.holdMs;
return { channels, holdMs, desc: act.desc };
}
/** 取设备某按键在当前平台的通道号。 */
export function resolveRelayButton(device: string, button: string): number {
return channelOf(device, button);
}

33
config/wifi.config.ts Normal file
View File

@ -0,0 +1,33 @@
/**
* WiFi Hub Mini WiFi
*
* (Android / iOS WiFi):
* env(WIFI_SSID_ANDROID / WIFI_SSID_IOS) > env(WIFI_SSID) > (woanmarket/woankeji)
* (WIFI_PASSWORD_ANDROID / WIFI_PASSWORD_IOS > WIFI_PASSWORD > woankeji)
*
* (.env shell):
* WIFI_SSID_ANDROID=woanmarket WIFI_PASSWORD_ANDROID=woankeji
* WIFI_SSID_IOS=woanmarket_5g WIFI_PASSWORD_IOS=xxxx
* (woanmarket/woankeji); .env
*/
export interface WifiCredentials {
ssid: string;
password: string;
}
const DEFAULT_SSID = 'woanmarket';
const DEFAULT_PASSWORD = 'woankeji';
function pick(platformVar: string, genericVar: string, def: string): string {
return process.env[platformVar] || process.env[genericVar] || def;
}
/** 取当前(或指定)平台的 WiFi 凭据。platform 缺省读 PLATFORM env(android/ios)。 */
export function getWifiCredentials(platform?: 'android' | 'ios'): WifiCredentials {
const isIos = (platform || process.env.PLATFORM || 'android').toLowerCase() === 'ios';
const suffix = isIos ? 'IOS' : 'ANDROID';
return {
ssid: pick(`WIFI_SSID_${suffix}`, 'WIFI_SSID', DEFAULT_SSID),
password: pick(`WIFI_PASSWORD_${suffix}`, 'WIFI_PASSWORD', DEFAULT_PASSWORD),
};
}

Binary file not shown.

View File

@ -1,618 +0,0 @@
#!/usr/bin/env python3
"""生成 UI自动化测试计划.docx"""
from docx import Document
from docx.shared import Inches, Pt, Cm, RGBColor
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.section import WD_ORIENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
import datetime
doc = Document()
# ======================== 样式设置 ========================
style = doc.styles['Normal']
style.font.name = '微软雅黑'
style.font.size = Pt(10.5)
style.element.rPr.rFonts.set(qn('w:eastAsia'), '微软雅黑')
def set_cell_shading(cell, color):
shading_elm = OxmlElement('w:shd')
shading_elm.set(qn('w:fill'), color)
shading_elm.set(qn('w:val'), 'clear')
cell._tc.get_or_add_tcPr().append(shading_elm)
def add_table_with_header(doc, headers, rows, col_widths=None):
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
table.style = 'Table Grid'
# Header
for i, h in enumerate(headers):
cell = table.rows[0].cells[i]
cell.text = h
cell.paragraphs[0].runs[0].bold = True
set_cell_shading(cell, '4472C4')
cell.paragraphs[0].runs[0].font.color.rgb = RGBColor(255, 255, 255)
# Data
for r_idx, row_data in enumerate(rows):
for c_idx, val in enumerate(row_data):
table.rows[r_idx + 1].cells[c_idx].text = str(val)
if col_widths:
for i, w in enumerate(col_widths):
for row in table.rows:
row.cells[i].width = Cm(w)
return table
# ======================== 封面 ========================
doc.add_paragraph()
doc.add_paragraph()
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title.add_run('SwitchBot App UI自动化测试计划')
run.font.size = Pt(26)
run.bold = True
doc.add_paragraph()
subtitle = doc.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = subtitle.add_run('单品设备功能测试 + 设备添加 + 自动化场景')
run.font.size = Pt(14)
run.font.color.rgb = RGBColor(68, 114, 196)
doc.add_paragraph()
doc.add_paragraph()
info = doc.add_paragraph()
info.alignment = WD_ALIGN_PARAGRAPH.CENTER
info.add_run(f'版本: v1.0\n').font.size = Pt(11)
info.add_run(f'日期: {datetime.date.today().strftime("%Y-%m-%d")}\n').font.size = Pt(11)
info.add_run('部门: 测试部\n').font.size = Pt(11)
doc.add_page_break()
# ======================== 1. 项目概述 ========================
doc.add_heading('1. 项目概述', level=1)
doc.add_heading('1.1 背景', level=2)
doc.add_paragraph(
'SwitchBot App 涵盖 100+ 款 IoT 设备,需要对每款设备的 App 功能进行自动化回归测试。'
'本计划基于已验证的 Vitest + Appium 技术框架,按优先级分层推进:'
'必测项 → 单品探索 → 全功能 → 平台,高优先级层先达标再投入下一层。'
)
doc.add_heading('1.2 技术栈', level=2)
rows = [
('框架', 'Vitest (TypeScript) + Appium'),
('驱动', 'UIAutomator2 (Android) / WebDriverAgent (iOS)'),
('AI辅助', 'Claude Code 边跑边写生成 & 调试脚本'),
('测试设备', 'Samsung 1080x2280 (Android) / iPhone 390x844pt (iOS)'),
('App', 'SwitchBot (com.theswitchbot.switchbot)'),
('报告', '自定义 HTML 报告 + JSON 结果'),
('用例管理', 'ONES 平台同步'),
]
add_table_with_header(doc, ['项目', '说明'], rows)
doc.add_heading('1.3 当前进度', level=2)
doc.add_paragraph('已调试完成的设备(不在本计划范围内):')
rows = [
('Bot', '5', '卡片/控制/设置/场景/日志', '100%'),
('Camera (Pan/Tilt Plus 3K)', '6', '卡片/控制/全屏/事件/录像/设置', '100%'),
('AI Hub', '11', '设置/投屏/侦测/勿扰/存储/回放/AI事件/日报/功能页/连接/绑定', '86%'),
]
add_table_with_header(doc, ['设备', '脚本文件数', '覆盖模块', '通过率'], rows)
doc.add_heading('1.4 目标', level=2)
doc.add_paragraph('• P0 必测项:各单品添加 + 核心控制(双协议)准入闸门,来源 ONES 必测项-AI自动化')
doc.add_paragraph('• P1 单品探索78 款单品主流程冒烟,覆盖广度')
doc.add_paragraph('• P2 全功能全维度回归card/control/setting/scene/logs覆盖深度')
doc.add_paragraph('• P3 平台:账号/房间/消息/家庭分享/场景等平台功能')
doc.add_paragraph('• 按优先级 backlog 依次交付,各层有独立退出标准(不绑定固定周次)')
doc.add_page_break()
# ======================== 2. 设备清单 ========================
doc.add_heading('2. 待覆盖设备清单78款', level=1)
device_categories = {
'窗帘 (9款)': [
('Curtain Rod', '已有脚本', '调试适配'),
('Curtain Rod 2', '已有脚本', '调试适配'),
('Curtain U Rail', '已有脚本', '调试适配'),
('Curtain U Rail 2', '已有脚本', '调试适配'),
('Curtain U Rail 2.3', '复用模板', '新增配置'),
('Curtain 3 U Rail', '复用模板', '新增配置'),
('Curtain 3 Rod 2025', '复用模板', '新增配置'),
('Curtain 4 Urail', '复用模板', '新增配置'),
('Roller Shade', '复用模板', '新增配置'),
],
'锁 (12款)': [
('Lock (JP)', '已有脚本', '调试适配'),
('Lock (US)', '已有脚本', '调试适配'),
('Lock (EU)', '复用模板', '新增配置'),
('Lock Pro (EU)', '复用模板', '新增配置'),
('Lock Pro (US)', '复用模板', '新增配置'),
('Lock Lite', '复用模板', '新增配置'),
('Lock Ultra (JP)', '需新写', 'UI差异大'),
('Lock Ultra (US)', '需新写', 'UI差异大'),
('Lock Ultra (EU)', '需新写', 'UI差异大'),
('Lock Pro Matter Enabled', '复用模板', '新增配置'),
('US Vision Deadbolt Pro', '需新写', '全新UI'),
('US Vision Deadbolt', '需新写', '全新UI'),
],
'灯光 (10款)': [
('LED Strip Light 2', '已有脚本', '调试适配'),
('Strip Light 3', '已有脚本', '调试适配'),
('RGBICWW Strip Light', '复用模板', '新增配置'),
('RGBICWW Floor Lamp', '复用模板', '新增配置'),
('RGBIC Neon Rope Light', '复用模板', '新增配置'),
('RGBIC Wire Neon Rope Light', '复用模板', '新增配置'),
('RGBICWW Ceiling Light', '已有脚本', '调试适配'),
('Floor Lamp', '复用模板', '新增配置'),
('Candle Lamp', '复用模板', '新增配置'),
('Permanent Outdoor Lights', '需新写', '户外新品'),
],
'插座+开关 (6款)': [
('Plug Mini (JP) HomeKit Enabled', '已有脚本', '调试适配'),
('Plug Mini (US) HomeKit Enabled', '已有脚本', '调试适配'),
('Plug Mini (EU)', '复用模板', '新增配置'),
('Relay Switch 1', '已有脚本', '调试适配'),
('Relay Switch 1PM', '复用模板', '新增配置'),
('Relay Switch 2PM', '复用模板', '新增配置'),
],
'扫地机 (9款)': [
('Robot Vacuum Cleaner S1', '已有脚本', '调试适配'),
('Robot Vacuum Cleaner S1 Plus (W)', '已有脚本', '调试适配'),
('Mini Robot Vacuum K10+Pro', '复用模板', '新增配置'),
('Robot Vacuum K20+ Pro', '需新写', '新一代UI'),
('Robot Vacuum K10+ Pro Combo', '复用模板', '新增配置'),
('Robot Vacuum K11+', '需新写', '新一代UI'),
('Robot Vacuum K11+ Pro', '需新写', '新一代UI'),
('Floor Cleaning Robot S20', '需新写', '全新UI'),
('Floor Cleaning Robot S20 Mini', '需新写', '全新UI'),
],
'传感器+温控 (7款)': [
('Indoor/Outdoor Thermo-Hygrometer', '已有脚本', '调试适配'),
('Meter Pro', '已有脚本', '调试适配'),
('Meter Pro (CO2 Monitor)', '复用模板', '新增CO2功能'),
('Water Leak Detector with Sensor Cable', '已有脚本', '调试适配'),
('Presence Sensor', '需新写', '全新UI'),
('Home Climate Panel', '需新写', '全新UI'),
('Weather Station', '需新写', '全新UI'),
],
'风扇+空净+加湿器 (6款)': [
('Circulator Fan', '已有脚本', '调试适配'),
('Standing Circulator Fan', '复用模板', '新增配置'),
('Circulator Fan Pro', '复用模板', '新增配置'),
('Air Purifier', '需新写', '全新品类'),
('Air Purifier Table', '需新写', '全新品类'),
('Evaporative Humidifier (Auto-refill)', '已有脚本', '调试适配'),
],
'摄像头+门铃 (3款)': [
('Pan/Tilt Cam 3MP猫狗定制款', '复用camera', '调试适配'),
('Video Doorbell', '需新写', '门铃UI差异'),
('OSC KVS', '已有脚本', '调试适配'),
],
'网关Hub (3款)': [
('Hub 3', '已有脚本', '调试适配'),
('AI Hub', '已完成', ''),
('AI Hub Show', '需新写', '带屏新品'),
],
'门控+安防+配件 (5款)': [
('Keypad Vision', '已有脚本', '调试适配'),
('Keypad Vision Pro', '复用模板', '新增配置'),
('Garage Door Opener', '需新写', '全新品类'),
('Safety Alarm', '需新写', '全新品类'),
('Smart Radiator Thermostat', '需新写', '全新品类'),
],
'AI产品 (5款)': [
('Art Frame', '需新写', '全新UI框架'),
('Art Frame luma', '需新写', '全新UI框架'),
('AI Pet', '需新写', '全新品类'),
('AI PinNote', '需新写', '全新品类'),
('Bot Rechargeable', '复用bot', '调试适配'),
],
'机器人+联名+其他 (4款)': [
('SwitchBot KATAフレンズ KUMAMON ver.', '复用bot', '特殊UI定制'),
('KATA Friends 国行版', '复用bot', '特殊UI定制'),
('Outdoor PTC', '需新写', '全新品类'),
('FindCard', '已有脚本', '调试适配'),
],
}
for cat_name, devices in device_categories.items():
doc.add_heading(cat_name, level=2)
add_table_with_header(doc,
['设备名称', '脚本状态', '备注'],
devices,
col_widths=[6, 3, 4]
)
doc.add_paragraph()
doc.add_page_break()
# ======================== 3. 测试维度说明 ========================
doc.add_heading('3. 测试维度说明', level=1)
doc.add_paragraph('每款设备根据功能复杂度,覆盖以下测试维度。人力预估基于 AI 辅助Claude Code 边跑边写):首台需建模分析,同品类复用台仅改配置,工时约减半或更低。')
rows = [
('card', '{device}_card.test.ts', '首页卡片展示与交互', '3-5', 'P1 / P2', '0.3 / 0.1'),
('control', '{device}_control.test.ts', '功能页核心操作(开关/模式/参数调节)', '8-20', 'P0 / P1 / P2', '1.0 / 0.3'),
('setting', '{device}_setting.test.ts', '设备设置页(名称/房间/固件/信息)', '5-8', 'P2', '0.5 / 0.2'),
('connect', '{device}_connect.test.ts', '设备添加/配对流程', '3-5', 'P0 / P2', '0.5 / 0.2'),
('scene', '{device}_scene.test.ts', '自动化/场景联动', '3-8', 'P3', '0.5 / 0.2'),
('logs', '{device}_logs.test.ts', '操作日志查看', '2-4', 'P2(可选)', '0.2 / 0.1'),
]
add_table_with_header(doc,
['维度', '文件命名', '内容', '用例数', '优先级层', 'AI预估(人日:首台/复用)'],
rows,
col_widths=[1.3, 3.8, 4.2, 1.2, 2, 3]
)
doc.add_paragraph()
doc.add_paragraph('用例数量估算:每设备平均 25-40 条78款设备总计约 2000-3000 条自动化用例。')
doc.add_paragraph('AI 提效口径:单维度脚本首台 0.2-1 人日、复用台 0.1-0.3 人日;串口添加框架为一次性投入(见 §5.2。下列各层人力预估均按此口径单位为人日1 人)。')
doc.add_page_break()
# ======================== 4. 测试优先级分层 ========================
doc.add_heading('4. 测试优先级分层', level=1)
doc.add_paragraph(
'本计划按优先级分四层推进,依次为:必测项 → 单品探索 → 全功能 → 平台。'
'高优先级层先达标再投入下一层;不绑定固定周次,按相对工作量排 backlog。'
)
rows = [
('P0 必测项', '各单品添加 + 核心控制(双协议)', 'ONES 必测项-AI自动化 (CQz9YCNX)', '187', '每次提测/版本必跑', '通过率≥95%'),
('P1 单品探索', '每单品主流程冒烟card+核心control', '按单品模板', '78款各1遍', '每迭代', '全单品冒烟通过'),
('P2 全功能', '全维度回归(card/control/setting/scene/logs)', '各品类用例库', '~2250', '版本回归', '通过率≥85%'),
('P3 平台', '账号/房间/消息/家庭分享/场景等平台功能', 'App平台用例库', '平台模块', '版本回归', '平台用例通过'),
]
add_table_with_header(doc,
['优先级', '范围', '来源', '用例量', '运行频率', '退出标准'],
rows,
col_widths=[2.2, 4, 3.5, 1.3, 2.5, 2.5]
)
doc.add_page_break()
# ======================== 5. P0 必测项 ========================
doc.add_heading('5. P0 必测项(最高优先)', level=1)
doc.add_paragraph(
'必测项是提测/版本更新的准入闸门,来源 ONES 测试计划「必测项-AI自动化」(plan CQz9YCNX / lib EPfZfC9Y)'
'映射清单见 test-plan/must-test.manifest.ts转换规则见 prompts/must_test_conversion.md。'
)
doc.add_heading('5.1 必测项构成', level=2)
rows = [
('添加 (connect)', '每单品型号一条「添加X验证」', '73', 'connect 维度,需串口配合'),
('单品功能 (feature)', '绑定手持/学习遥控器/keypad密码指纹/温湿度单位等', '9', 'control/setting 维度'),
('控制 (control)', '2条协议超级用例逐 stepBLE 49 + WiFi 56', '105', 'control 维度,双协议各跑一遍'),
]
add_table_with_header(doc, ['类型', '说明', '条数', '落点/备注'], rows)
doc.add_paragraph()
p = doc.add_paragraph()
p.add_run('合计:').bold = True
p.add_run('187 条(另 2 条 nasafety alarm 无目录、强绑定解绑归平台)。控制粒度在 step按 (用例号#step)+协议精确回写 ONES。')
doc.add_heading('5.2 前置:设备添加自动化(关键路径,最先启动)', level=2)
doc.add_paragraph(
'必测含 73 条添加,添加流程需硬件配合(串口控制设备进入配对模式),分 BLE 直连与 Wi-Fi 配网两种模式。'
'此为 P0 第一阻塞项,须最先启动;控制部分(无需串口)可并行先行。'
)
rows = [
('串口命令协议文档', '统一所有设备“进入配对模式”的串口指令', '嵌入式+测试'),
('串口控制模块', 'serial_controller.ts (基于 serialport库)', '测试'),
('添加流程通用框架', 'connect_base_helper.ts', '测试'),
('Wi-Fi 配网模块', 'wifi_connect_helper.ts', '测试'),
('硬件环境', 'USB Hub + USB转TTL × 5-10', '测试'),
]
add_table_with_header(doc, ['任务', '输出', '负责'], rows)
doc.add_paragraph()
p = doc.add_paragraph()
p.add_run('框架一次性投入:').bold = True
p.add_run('serial_controller + connect_base_helper + wifi_connect_helper ≈ 1-1.5 人日纯代码serial 基于现成桌面 serialport 工具薄封装、add 流程复用现有 addDeviceViaBLE仅 wifi 配网 helper 需新写。硬件联调USB-TTL 接线/端口/时序)由嵌入式主导,为 wall-clock 不确定项,不计入纯人日。')
doc.add_paragraph(
'关键:添加流程 app 侧通用,逐设备差异仅为“开发版上进入配对模式的按键/串口指令”,由嵌入式提供的「设备→串口键」配置表驱动,逐设备近乎零编码。'
'app 侧只需按类别区分设置步骤BLE 直连 / Wi-Fi 配网 / 带屏 / 锁含校准),约 4-5 类,而非 73 份脚本。'
)
doc.add_heading('5.3 添加 backlog通用流程 + 配置表驱动)', level=2)
rows = [
('首验', 'BLE直连(Bot/Meter/Sensor) + Wi-Fi配网框架打通', '端到端 demo', '0.5'),
('BLE 直连类', '窗帘/传感器/风扇/遥控器/插座开关/灯 等', '设备→串口键配置 + 验证', '1'),
('Wi-Fi 配网类', 'Hub/摄像头/扫地机 等(额外配网步骤)', '配网流程 + 配置', '1.5'),
('带屏 / 锁类', 'AI Hub Show / Lock 系列(含校准)', '类别专属设置步骤', '1.5'),
]
add_table_with_header(doc, ['批次', '范围', '交付物', 'AI预估(人日)'], rows)
doc.add_paragraph('添加小计:约 4-6 人日(流程通用、按 4-5 类区分;逐设备仅配置串口键,成本主要在逐台硬件验证的 wall-clock非人日')
doc.add_heading('5.4 控制 backlog双协议105 step', level=2)
doc.add_paragraph('• 协议为运行模式PROTO=ble关WiFi/热点、开蓝牙)/ PROTO=wifi开WiFi/热点、关蓝牙),各跑一遍。')
doc.add_paragraph('• camera/robot/osc 仅 WiFiBot/Lock/Curtain/Meter 等两协议都覆盖。')
doc.add_paragraph('• 控制内容含开关/开锁/百分比/meter温湿度校正/camera出流3min/robot清扫暂停回充/Humidifier2绑温湿度计。')
doc.add_paragraph('• 结果按 [ONES:号#step][协议] 锚点 step 级回写 plan CQz9YCNX。')
doc.add_paragraph('控制小计:约 3-4 人日(大部分品类已有 _control 用例 → P0 主要是给既有断言打 [P0][#step][协议] 锚点 + 双协议切换 harness ~1 人日 + 补少量缺口;双协议第二遍仅切前置、增量低)。')
doc.add_paragraph()
p = doc.add_paragraph()
p.add_run('P0 合计:').bold = True
p.add_run('约 11-15 人日(串口框架~1-1.5 + 添加 4-6 + 控制 3-4 + 功能9条~3 + step回写工具~0.5)。控制大部分品类已有 _control 用例可复用,主要是打锚点 + 双协议 harness功能 9 条为 keypad 密码指纹/学习遥控/绑定手持等较复杂项;硬件联调由嵌入式主导,单列不计入。')
doc.add_page_break()
# ======================== 6. P1 单品探索 ========================
doc.add_heading('6. P1 单品探索(覆盖广度)', level=1)
doc.add_paragraph(
'必测项达标后,为全部 78 款单品覆盖一遍探索测试card + 核心 control并扩展到'
'全品类核心功能的「部分全功能用例」(每品类挑代表性主功能,不做完整深度回归)。'
)
doc.add_paragraph('• 目标78 款单品各 1 套探索集card + 核心控制 + 品类代表性主功能),广度优先。')
doc.add_paragraph('• 已有/复用品类:复用 P0 已建 control helper补 card 与主功能断言,较快。')
doc.add_paragraph('• 新品未支持:扫地机新机 / Lock Ultra·Vision / AI产品 / Art Frame 等 ~26 款为全新 UI'
'P1 需先建该 UI 的探索脚本支持,这部分较贵、是 P1 的主要成本。')
doc.add_paragraph('• 退出标准每款单品探索集通过card 可见 + 核心控制响应 + 代表性主功能可用)。')
doc.add_paragraph('• 运行频率:每迭代跑一遍,作为单品级回归基线。')
doc.add_paragraph('• 旧代码转换提效:部分用例由以前旧代码转换而来,已含测试路径/步骤只需定位元素位置使脚本可执行Midscene AI 定位较快)——这类省去写用例的工作。新品无旧码、不受益。')
doc.add_paragraph('• 人力预估AI 辅助):约 16-22 人日(已有/复用品类 ~52 款较快,部分还有旧码转换的现成路径;新品 ~26 款需首次建新 UI 探索支持,为主要成本)。')
doc.add_page_break()
# ======================== 7. P2 全功能 ========================
doc.add_heading('7. P2 全功能(深度回归)', level=1)
doc.add_paragraph(
'对每款单品做全维度回归card/control/setting/scene/logs。按品类批量推进'
'同品类 UI 高度相似,调通首台后改设备名配置快速扩展,并沉淀通用 helper。'
'不绑定周次,按复用度从高到低排批次。'
)
doc.add_paragraph(
'脚本状态分三层成本(见第 2 节):「已有脚本」只需在真机上调试适配,最快;'
'「复用模板」改设备名/配置次之;「需新写」(全新 UI最贵。估算按此分层。'
)
doc.add_paragraph(
'进一步提效:功能页/设置页「入口」全单品通用(沉淀一个全局导航 helper不逐设备重写'
'同品类功能测试点高度雷同,首台调通后其余只调「差异功能」。'
'另有部分用例由旧代码转换而来、已含路径,只需定位元素即可执行。故 P2 实际只在每设备增量调试少量差异点。'
)
rows = [
('批1·高复用', '窗帘9 / 锁12 / 插座开关6 / 灯光10', '37', '差异功能 + 通用入口 helper', '~12'),
('批2·中复杂', '扫地机9 / 传感器温控7 / 风扇空净加湿6 / 摄像头门铃3', '25', '差异功能 + robot/sensor/climate helper', '~14'),
('批3·新品特殊', 'Hub门控安防 / AI产品 / 联名其他(KATA/FindCard/PTC)', '16', '深度回归(UI支持已在P1建好)', '~10'),
]
add_table_with_header(doc,
['批次', '品类', '设备数', '交付物', 'AI预估(人日)'],
rows,
col_widths=[2.3, 5, 1.3, 5, 2.2]
)
doc.add_paragraph()
p = doc.add_paragraph()
p.add_run('覆盖目标:').bold = True
p.add_run('全部 78 款单品全维度回归,~2250 条用例,单品功能覆盖率 100%')
p = doc.add_paragraph()
p.add_run('P2 合计:').bold = True
p.add_run('约 25-35 人日。入口通用、同品类只调差异功能、card/control 与新品 UI 已在 P0/P1 建好;且已有品类不少用例由旧代码转换、已含路径,只需定位元素(Midscene AI 定位)——P2 主要剩真机跑通+少量差异深度。已接近实际地板:每设备至少真机验一次 + flaky 复跑不可再压。注:P0+P1+P2 总量守恒。')
doc.add_heading('7.1 高复用品类要点', level=2)
doc.add_paragraph('• 窗帘: 位置控制、校准流程、定时器、群组控制 → curtain_helper')
doc.add_paragraph('• 锁: 密码管理、开锁方式切换、自动锁定、电量监控 → lock_helper')
doc.add_paragraph('• 插座开关: 开关、定时器、功率/电量统计、过载保护 → relay_helper')
doc.add_paragraph('• 灯光: RGB颜色、亮度、模式切换、渐变 → light_helper')
doc.add_heading('7.2 中复杂品类要点', level=2)
doc.add_paragraph('• 扫地机: 地图管理/禁区/多楼层、清扫模式、自清洁基站(地图画布操作为难点)')
doc.add_paragraph('• 传感器+温控: 温湿度展示、历史图表、告警、灵敏度(图表验证需截图/文本提取)')
doc.add_paragraph('• 风扇+空净+加湿: 风速/模式/摇头/定时、空气质量/滤网、湿度目标/水箱')
doc.add_paragraph('• 摄像头+门铃: 出流/录像/事件、门铃呼叫/访客/对讲、OSC 复用')
doc.add_heading('7.3 新品+特殊要点', level=2)
doc.add_paragraph('• Hub 3 / AI Hub Show: 设备管理、红外学习、Matter、带屏交互')
doc.add_paragraph('• 门控安防: Garage Door、Safety Alarm、Radiator Thermostat')
doc.add_paragraph('• AI产品: Art Frame、AI Pet、AI PinNote全新UI需从Figma/UX重新分析')
doc.add_paragraph('• 联名+其他: KATA FriendsBot复用+定制皮肤、FindCard已有脚本调试适配、Outdoor PTC需新写')
doc.add_page_break()
# ======================== 8. P3 平台 ========================
doc.add_heading('8. P3 平台功能', level=1)
doc.add_paragraph(
'单品三层达标后,覆盖跨单品的平台级功能(必测项中约 12 条平台项 + 平台用例库)。'
)
doc.add_heading('8.1 平台功能范围', level=2)
rows = [
('账号', '注册/登录/登出/忘记密码/第三方登录/欧区账号', '账号2.0'),
('家庭与房间', '创建房间、家庭分享3.0、家庭分组', '分享/分组用例库'),
('消息与商城', '消息中心3.0、商城、我的、小组件', '对应平台库'),
('其他', '覆盖安装、插件热更、第三方服务接入', 'App必测项平台模块'),
]
add_table_with_header(doc, ['模块', '内容', '来源'], rows)
doc.add_heading('8.2 自动化/场景联动', level=2)
doc.add_paragraph('验证设备间联动可靠性(复用已有 automation 脚本基础)。场景联动里的设备功能入口/控制 P0-P2 已调通,联动部分调试较快;账号/房间/消息/分享为纯平台功能,不复用设备脚本。')
doc.add_paragraph('• 创建自动化:手动/条件触发/定时触发')
doc.add_paragraph('• 执行验证:触发条件满足后动作执行')
doc.add_paragraph('• 编辑/删除:修改条件或动作、删除自动化')
doc.add_paragraph('• 多设备联动A设备触发 → B设备执行')
doc.add_paragraph('• 异常处理:设备离线时的自动化表现')
doc.add_page_break()
# ======================== 8. 资源需求 ========================
doc.add_heading('9. 资源需求汇总', level=1)
doc.add_heading('9.1 人力与分工', level=2)
rows = [
('自动化测试工程师', '1人', '全职、全程,承担全部脚本编写与调试'),
('嵌入式工程师(配合)', '按需', '提供串口协议与配对框架支持P0 添加阻塞项),最优先'),
('AI辅助 (Claude Code)', '', '边跑边写生成+调试脚本,贯穿全程提效'),
]
add_table_with_header(doc, ['角色', '人数', '说明'], rows)
doc.add_paragraph()
doc.add_paragraph(
'人力安排:仅 1 名自动化工程师四层按优先级串行推进AI 辅助提效。'
'P0 内部拆两条轨——「控制轨」不依赖串口可先行,「添加轨」待嵌入式串口框架就绪后插入,两轨由同一人分时推进。'
)
rows = [
('P0 · 控制轨', '自动化(1人)', '', '先行:双协议控制 105 step不待串口'),
('P0 · 添加轨', '自动化(1人)', '嵌入式(串口协议+框架)', '串口就绪后插入73 添加 + 9 功能'),
('P1 单品探索', '自动化(1人)', '', 'P0 达标后78 款冒烟'),
('P2 全功能', '自动化(1人)', '', '按品类批量,复用 helper'),
('P3 平台', '自动化(1人)', '', '最后:平台功能 + 场景联动'),
]
add_table_with_header(doc,
['优先级层', '主负责', '配合', '安排说明'],
rows,
col_widths=[2.5, 2.5, 3.5, 6]
)
doc.add_heading('9.2 硬件设备', level=2)
doc.add_paragraph('每品类至少需要1台实体设备用于调试和验证')
rows = [
('P0 串口硬件', 'USB Hub + USB转TTL×5-10、开发版设备', '最先到位(添加为关键路径)'),
('P0/P1 必测+冒烟', '必测涉及的各品类代表设备各1台', '随 P0 启动'),
('P2 高复用', '窗帘×1, 锁×2, 插座×1, 开关×1, 灯×3', '全功能批1前到位'),
('P2 中复杂', '扫地机×2, 传感器×3, 风扇×1, 空净×1, 加湿器×1, 门铃×1', '全功能批2前到位'),
('P2 新品特殊', 'Hub 3, AI Hub Show, Art Frame, AI Pet等', '全功能批3前到位'),
]
add_table_with_header(doc, ['用途', '设备需求', '到位时间'], rows)
doc.add_heading('9.3 环境', level=2)
rows = [
('测试机', 'Mac×2双调试位+ Android×2Samsung 等)+ iOS×2iPhone', '已有'),
('Appium Server', 'v2.x + UIAutomator2 / WebDriverAgent', '已有'),
('网络环境', '稳定Wi-Fi (Deco)', '已有'),
('CI/CD', 'Jenkins/GitHub Actions (可选)', '待搭建'),
]
add_table_with_header(doc, ['项目', '说明', '状态'], rows)
doc.add_paragraph('硬件并行2 Mac + 2 Android + 2 iOS压缩日历')
doc.add_paragraph('• Android∥iOS 并行Mac1+Android、Mac2+iOS 同时推进iOS 增量(25-45 人日)与 Android 时间线重叠,不再串行累加(最大杠杆)。')
doc.add_paragraph('• 每平台 2 台:一台调试、另一台跑全量回归/flaky 复跑(白天+夜间),执行类 wall-clock 再砍。')
doc.add_paragraph('• 双 Mac 各挂一个 AI agentClaude Code/Midscene并行“边跑边写”人只做 review——唯一能突破“1 人”瓶颈的方式。')
doc.add_paragraph('• 上限:仍 1 人手动交互调试不可一人并干两台Amdahl正确用法=「1 台主调 + 1 台挂 AI/跑回归」。')
doc.add_paragraph('• 净效果:双平台日历 ~90-130 → ~65-90 工作日(≈ 压回单平台水平),人日(工作量)不变;提速幅度取决于多少调试可交给 AI 自动跑。')
doc.add_page_break()
# ======================== 10. 优先级 backlog 一览 ========================
doc.add_heading('10. 优先级 backlog 一览', level=1)
rows = [
('P0 必测项', '添加73 + 功能9 + 控制105step(双协议)', '187条', '~11-15', 'serial框架 + connect + 控制断言 + step回写', '通过率≥95%'),
('P1 单品探索', '全品类 card+核心control + 代表性主功能(部分全功能);含新品UI首次支持', '78款各1套', '~16-22', '探索集脚本 + 新品UI支持', '全品类探索集通过'),
('P2 全功能', '全维度深度回归(setting/scene/logs+完整功能点)', '~2250条', '~25-35', '深度回归(入口/路径多已就绪,补定位)', '通过率≥85%、覆盖100%'),
('P3 平台', '账号/房间/消息/家庭分享/场景等', '平台模块', '~10-13', '平台用例 + 场景联动(复用设备入口)', '平台用例通过'),
]
add_table_with_header(doc,
['优先级', '范围', '用例量', 'AI预估(人日,单平台)', '交付物', '退出标准'],
rows,
col_widths=[2, 4.3, 1.5, 2.2, 4, 2.5]
)
doc.add_paragraph()
p = doc.add_paragraph()
p.add_run('人力总计AI 辅助口径,单平台):').bold = True
p.add_run('约 65-85 人日 ≈ 3.5-4.5 人月1 人,按 20 人日/月。P1 含新品(~26款)全新 UI 首次探索支持,是 P1 主成本;P2 因入口通用、card/control 与新品 UI 已在 P0-P1 建好、且不少用例由旧代码转换已含路径(只需定位元素),只做剩余深度回归。已接近地板:每设备至少真机验一次 + 新品 UI 分析 + flaky 复跑不可再压。P0+P1+P2 总量守恒。P3 纯平台功能不复用设备脚本。串口硬件联调由嵌入式主导、不计入。')
doc.add_paragraph()
p = doc.add_paragraph()
p.add_run('双平台Android + iOS').bold = True
p.add_run('以上为单平台口径(主调 Android)。iOS 增量约 +40-60%(非 ×2):用例逻辑/路径/helper 共享,'
'Midscene AI 视觉定位跨平台大多可复用(省去两套选择器重写);增量主要是 iOS 真机执行+flaky 复跑、'
'及平台差异(iOS 权限弹窗 / 返回交互 / 配网与添加流程 / 个别布局)。各层增量按其真机/差异占比分摊(P0 偏低,见下),分布如下:')
rows = [
('P0 必测项', '11-15', '3-5', '14-20'),
('P1 单品探索', '16-22', '6-13', '22-35'),
('P2 全功能', '25-35', '10-21', '35-56'),
('P3 平台', '10-13', '4-8', '14-21'),
('合计', '65-85', '25-45', '90-130'),
]
add_table_with_header(doc, ['优先级', '单平台(人日)', 'iOS增量(人日)', '双平台合计(人日)'], rows)
doc.add_paragraph('iOS 增量大头在 P2(深度回归真机执行最多)。P0 偏低:添加流程 app 侧通用(iOS 只适配一次 add 流程 + 配置驱动验证)'
'串口框架(对硬件)与 step 回写(ONES API)与平台无关、iOS 零增量,故 P0 仅控制+少量添加差异有增量。'
'P3 仅权限/登录小幅增量。')
doc.add_paragraph()
p = doc.add_paragraph()
p.add_run('夜间并行 iOS 压缩日历(省周期,不省人力):').bold = True
p.add_run('白天调 AndroidiOS 夜间挂测——让 Midscene/AI 无人值守跑全量+flaky 复跑+自动改定位,次日早上 review 失败项。'
'iOS 增量(~26-51 人日)中“机器执行类”可藏进夜间闲置时段(约占一半到三分之二)'
'“人工判断类”(iOS 特有差异、AI 改不动的)仍占用同一人白天工时、省不掉。')
rows = [
('串行双平台', '~90-130 工作日', 'Android 调完再调 iOS'),
('夜间并行 iOS', '~70-100 工作日(省约 15-35 天)', '理想可压回接近单平台'),
]
add_table_with_header(doc, ['方式', '日历(1人)', '说明'], rows)
doc.add_paragraph('省多少取决于:① iOS 真机夜间在位、app 状态可自动复位;② iOS 平台差异多少;③ AI 自动修定位成功率。'
'注意:压缩的是日历周期,人日(工作量)不变;夜测靠 AI 跑+早上 review非人工加班可持续。')
doc.add_paragraph(
'人力构成说明:以上为 AI 辅助口径(较手工已下调约 2-3 倍)。其中“脚本编写”仅占约 1/3——AI 提效主要在此;'
'其余约 2/3 是真机调试元素核对、加载等待、flaky 复跑)、设备/硬件配置与新品 UI 分析,受真机往返与 UI 稳定性约束AI 难以进一步压缩。'
'项目使用 MidsceneAI 元素定位)已减少传统选择器维护成本,故未再按“零调试”乐观值估算。'
'若需再压缩周期,杠杆在于:多台真机并行、收窄 P2 范围(只保高频设备)、放宽 flaky 容忍度,而非提高编写速度。'
)
doc.add_paragraph()
p = doc.add_paragraph()
p.add_run('说明:').bold = True
p.add_run('按优先级依次交付高层达标后投入下一层不绑定固定周次。P0 控制部分可不待串口先行,添加随串口框架就绪推进。')
doc.add_page_break()
# ======================== 10. 质量标准 ========================
doc.add_heading('11. 质量标准与验收', level=1)
doc.add_heading('11.1 单设备验收标准', level=2)
doc.add_paragraph('• 核心用例通过率 ≥ 85%(首轮调试)')
doc.add_paragraph('• 单次全量执行时间 < 10分钟/设备')
doc.add_paragraph('• 用例覆盖card + control + setting 三个维度必须覆盖')
doc.add_paragraph('• 脚本可在 Android/iOS 双平台运行(通过平台适配层)')
doc.add_heading('11.2 优先级层验收标准', level=2)
rows = [
('P0 必测项', '187条全部实现并跑通添加+控制双协议)', '通过率≥95%,作为提测门禁'),
('P1 单品探索', '78款单品冒烟全部通过', '广度100%覆盖'),
('P2 全功能', '全维度回归通过率≥85%', '单品功能覆盖率100%'),
('P3 平台', '平台功能 + 场景联动通过', '全流程覆盖'),
]
add_table_with_header(doc, ['优先级', '验收标准', '覆盖目标'], rows)
doc.add_heading('11.3 回归策略', level=2)
doc.add_paragraph('• P0 必测项:每次提测/版本更新必跑(双协议),作为准入门禁')
doc.add_paragraph('• P1 单品探索:每迭代执行一遍全单品冒烟')
doc.add_paragraph('• P2 全功能:版本回归执行累计设备全量')
doc.add_paragraph('• P3 平台:版本回归执行平台功能与场景联动')
doc.add_page_break()
# ======================== 11. 风险与依赖 ========================
doc.add_heading('12. 风险与依赖', level=1)
doc.add_heading('12.1 关键依赖', level=2)
rows = [
('串口/添加为关键路径(前移)', '', '硬件或协议不就绪将阻塞 P0 整体', '控制部分(无需串口)先行,串口框架并行启动'),
('实体设备到位', '', '无设备无法调试', 'P0 涉及的代表设备最先准备'),
('嵌入式串口协议', '', '直接影响 P0 添加', '最优先启动协议定义'),
('同品类UI一致性', '', 'UI差异大则无法复用', '差异设备单独排期'),
('新品UI稳定', '', 'UI仍在迭代会导致返工', '等UI冻结后再开始(主要影响P2新品)'),
('App版本稳定', '', '频繁改版影响脚本维护', '锁定测试版本'),
]
add_table_with_header(doc, ['依赖项', '风险等级', '影响', '缓解措施'], rows)
doc.add_heading('12.2 待讨论问题', level=2)
doc.add_paragraph('1. 设备添加的串口开发版/协议时间线?(决定 P0 添加何时能启动)')
doc.add_paragraph('2. 必测项控制双协议环境切换开关WiFi/蓝牙)能否用 adb/串口自动化,还是需人工配合?')
doc.add_paragraph('3. 已下市产品Hub Plus、老灯带、加湿器1等是否纳入 P2 全功能?')
doc.add_paragraph('4. 同一设备的不同区域版本JP/US/EUUI差异程度是否需逐个调试')
doc.add_paragraph('5. CI/CD 持续集成环境是否需要在 P0 之前搭建?')
# ======================== 保存 ========================
output_path = '/Users/woan/Desktop/AI_UIAutomation/docs/UI自动化测试计划.docx'
doc.save(output_path)
print(f'✓ 文档已生成: {output_path}')

View File

@ -47,6 +47,12 @@ export class AndroidDriver implements DeviceDriver {
};
const result = await this.request('POST', '/session', { capabilities });
this.sessionId = result.sessionId;
// 首页有实时读数(温湿度)持续刷新,UI 永不 idle → 默认 waitForIdleTimeout(10s)会让
// 每次 getSource/查找都白等满。但设为 0 又会在添加流程的连续跳转页抓到"未渲染完"的页面
// (品类网格没加载完就误判设备缺失→狂滑)。折中 1s:既不死等 10s,也给跳转页一点渲染时间。
await this.request('POST', `/session/${this.sessionId}/appium/settings`, {
settings: { waitForIdleTimeout: 1000 },
}).catch(() => { /* 老版本 driver 不支持则忽略 */ });
await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId: APP_CONFIG.android.appPackage });
await new Promise(r => setTimeout(r, 3000));
}
@ -198,30 +204,39 @@ export class AndroidDriver implements DeviceDriver {
async swipe(fromX: number, fromY: number, toX: number, toY: number, duration = 0.5): Promise<void> {
const durationMs = Math.round(duration * 1000);
await this.request('POST', `/session/${this.sessionId}/actions`, {
const body = {
actions: [{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: Math.round(fromX), y: Math.round(fromY) },
{ type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(fromX), y: Math.round(fromY) },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerMove', duration: durationMs, x: Math.round(toX), y: Math.round(toY) },
{ type: 'pointerMove', duration: durationMs, origin: 'viewport', x: Math.round(toX), y: Math.round(toY) },
{ type: 'pointerUp', button: 0 },
],
}],
});
};
// UIA2 偶发 "Unable to perform W3C actions" 瞬时抖动 → 清理手势链后重试一次
try {
await this.request('POST', `/session/${this.sessionId}/actions`, body);
} catch (e: any) {
if (!/W3C actions/i.test(e.message || '')) throw e;
await this.request('DELETE', `/session/${this.sessionId}/actions`).catch(() => {});
await new Promise((r) => setTimeout(r, 500));
await this.request('POST', `/session/${this.sessionId}/actions`, body);
}
}
async scrollDown(distance = 300): Promise<void> {
const size = await this.getWindowSize();
const size = await this.getWindowSizeCached();
const startX = size.width / 2;
const startY = size.height / 2;
await this.swipe(startX, startY, startX, startY - distance, 0.5);
}
async scrollUp(distance = 300): Promise<void> {
const size = await this.getWindowSize();
const size = await this.getWindowSizeCached();
const startX = size.width / 2;
const startY = size.height / 2;
await this.swipe(startX, startY, startX, startY + distance, 0.5);
@ -240,6 +255,13 @@ export class AndroidDriver implements DeviceDriver {
return { width: result.width, height: result.height };
}
private _winSize: { width: number; height: number } | null = null;
/** 窗口尺寸缓存:滚动等高频调用不必每次往返查询(屏幕尺寸一个会话内不变)。 */
async getWindowSizeCached(): Promise<{ width: number; height: number }> {
if (!this._winSize) this._winSize = await this.getWindowSize();
return this._winSize;
}
async screenshot(): Promise<string> {
return await this.request('GET', `/session/${this.sessionId}/screenshot`);
}
@ -301,22 +323,36 @@ export class AndroidDriver implements DeviceDriver {
}
async dismissPopupIfPresent(): Promise<boolean> {
const source = await this.getSource();
if (source.includes('android.app.Dialog') || source.includes('permission') ||
source.includes('Upgrade') || source.includes("What's New") ||
source.includes('AlertDialog') || source.includes('Please Note') ||
source.includes('Restart') || source.includes('Got it')) {
const dismissTexts = ['OK', 'Got it', 'Close', 'Cancel', 'Confirm', 'Allow',
'Later', 'Skip', 'Done', 'ALLOW', 'DENY', 'Not Now'];
// 关闭按钮(只点"否定/关闭"类,避免误点"更新/同意"推进);中英都覆盖
const dismissTexts = [
'Cancel', 'Close', 'Later', 'Maybe Later', 'Not Now', 'No Thanks', 'No, Thanks', 'Dismiss', 'Skip', 'Ignore',
'Got it', 'OK', 'I know', 'I Know', 'Done', 'Allow', 'ALLOW', 'DENY', 'While using the app',
'取消', '关闭', '稍后', '以后再说', '暂不', '暂不更新', '忽略', '跳过', '我知道了', '知道了', '好的', '允许', '不用了',
];
// 触发:对话框/常见弹框关键字(扩充:更新/评分/新功能/通知/试用/优惠/连接失败 等)
const triggers = [
'android.app.Dialog', 'AlertDialog', 'permission', 'Upgrade', 'Update', "What's New", 'New Feature',
'Please Note', 'Restart', 'Got it', 'Rate', 'Review', 'Reminder', 'Notification', 'Subscribe', 'Trial', 'Welcome', 'Tips',
'Failed to connect', 'connect to your device', 'Connection failed', 'Try Again',
'更新', '升级', '新功能', '评分', '评价', '提示', '通知', '订阅', '试用', '体验', '优惠', '连接失败', '请注意', '温馨提示',
];
let dismissed = false;
// 循环清掉叠加的多个弹框(最多 4 个)
for (let round = 0; round < 4; round++) {
const source = await this.getSource();
if (!triggers.some(t => source.includes(t))) break;
let hit = false;
for (const text of dismissTexts) {
const el = await this.findElementRaw('-android uiautomator', `new UiSelector().text("${text}")`);
if (el) {
await this.tapElement(el);
await new Promise(r => setTimeout(r, 1000));
return true;
dismissed = true; hit = true;
break;
}
}
if (!hit) break; // 有触发词但没找到可点按钮,停止避免空转
}
return false;
return dismissed;
}
}

View File

@ -71,6 +71,11 @@ export class WDADriver implements DeviceDriver {
return this.wda.typeText(elementId, text);
}
/** 向聚焦输入框发送键盘按键(iOS 自绘输入框用)。 */
async sendKeys(keys: string[]): Promise<void> {
return this.wda.sendKeys(keys);
}
async clearText(elementId: string): Promise<void> {
return this.wda.clearText(elementId);
}

View File

@ -67,7 +67,7 @@ function checkAppium(): Promise<boolean> {
}
export async function setup() {
TestReporter.clearSharedResults();
if (!process.env.SOAK_MODE) TestReporter.clearSharedResults(); // soak 模式跨多次 run 累积,不每次清空
console.log(`[global-setup] Platform: ${platform}`);
if (platform === 'android') {
const ready = await checkAppium();
@ -82,6 +82,7 @@ export async function setup() {
}
export function teardown() {
if (process.env.SOAK_MODE) return; // soak 模式:由 soak 脚本在全程结束后统一出一份报告
const p = process.env.PLATFORM || 'ios';
TestReporter.generateCombinedReport(`AIHub_${p.toUpperCase()}`);
}

491
package-lock.json generated
View File

@ -13,11 +13,16 @@
"@midscene/ios": "^1.8.1",
"@types/node": "^25.8.0",
"dotenv": "^17.4.2",
"jsqr": "^1.4.0",
"pngjs": "^7.0.0",
"serialport": "^13.0.0",
"ts-node": "^10.9.2",
"typescript": "^6.0.3"
},
"devDependencies": {
"@midscene/web": "^1.8.1",
"@types/pngjs": "^6.0.5",
"qrcode": "^1.5.4",
"vitest": "^4.1.6"
}
},
@ -1730,6 +1735,211 @@
"dev": true,
"license": "MIT"
},
"node_modules/@serialport/binding-mock": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz",
"integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==",
"license": "MIT",
"dependencies": {
"@serialport/bindings-interface": "^1.2.1",
"debug": "^4.3.3"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@serialport/bindings-cpp": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz",
"integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@serialport/bindings-interface": "1.2.2",
"@serialport/parser-readline": "12.0.0",
"debug": "4.4.0",
"node-addon-api": "8.3.0",
"node-gyp-build": "4.8.4"
},
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz",
"integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz",
"integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==",
"license": "MIT",
"dependencies": {
"@serialport/parser-delimiter": "12.0.0"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/bindings-interface": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz",
"integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==",
"license": "MIT",
"engines": {
"node": "^12.22 || ^14.13 || >=16"
}
},
"node_modules/@serialport/parser-byte-length": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz",
"integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-cctalk": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz",
"integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-delimiter": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz",
"integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-inter-byte-timeout": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz",
"integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-packet-length": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz",
"integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==",
"license": "MIT",
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/@serialport/parser-readline": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-13.0.0.tgz",
"integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==",
"license": "MIT",
"dependencies": {
"@serialport/parser-delimiter": "13.0.0"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-ready": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-13.0.0.tgz",
"integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-regex": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-13.0.0.tgz",
"integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-slip-encoder": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz",
"integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-spacepacket": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz",
"integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/stream": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz",
"integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==",
"license": "MIT",
"dependencies": {
"@serialport/bindings-interface": "1.2.2",
"debug": "4.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@silvia-odwyer/photon": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@silvia-odwyer/photon/-/photon-0.3.3.tgz",
@ -1842,6 +2052,16 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/pngjs": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
"integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -2392,6 +2612,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -2621,6 +2851,16 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
@ -2720,6 +2960,13 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@ -3286,6 +3533,20 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@ -3746,6 +4007,12 @@
"jsonrepair": "bin/cli.js"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@ -4019,6 +4286,19 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
@ -4192,6 +4472,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/node-addon-api": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz",
"integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@ -4230,6 +4519,17 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -4332,6 +4632,45 @@
"opener": "bin/opener-bin.js"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pac-proxy-agent": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
@ -4375,6 +4714,16 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -4467,6 +4816,15 @@
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/portfinder": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
@ -4599,6 +4957,90 @@
"node": ">=18"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@ -4648,6 +5090,13 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@ -4814,6 +5263,34 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/serialport": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz",
"integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==",
"license": "MIT",
"dependencies": {
"@serialport/binding-mock": "10.2.2",
"@serialport/bindings-cpp": "13.0.0",
"@serialport/parser-byte-length": "13.0.0",
"@serialport/parser-cctalk": "13.0.0",
"@serialport/parser-delimiter": "13.0.0",
"@serialport/parser-inter-byte-timeout": "13.0.0",
"@serialport/parser-packet-length": "13.0.0",
"@serialport/parser-readline": "13.0.0",
"@serialport/parser-ready": "13.0.0",
"@serialport/parser-regex": "13.0.0",
"@serialport/parser-slip-encoder": "13.0.0",
"@serialport/parser-spacepacket": "13.0.0",
"@serialport/stream": "13.0.0",
"debug": "4.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@ -4829,6 +5306,13 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -5792,6 +6276,13 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

@ -29,11 +29,16 @@
"@midscene/ios": "^1.8.1",
"@types/node": "^25.8.0",
"dotenv": "^17.4.2",
"jsqr": "^1.4.0",
"pngjs": "^7.0.0",
"serialport": "^13.0.0",
"ts-node": "^10.9.2",
"typescript": "^6.0.3"
},
"devDependencies": {
"@midscene/web": "^1.8.1",
"@types/pngjs": "^6.0.5",
"qrcode": "^1.5.4",
"vitest": "^4.1.6"
}
}

View File

@ -175,6 +175,24 @@ reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, ...);
---
## 8.1 特殊用例:插件热更 / 获取RN插件(平台 · Zendesk,工单号为变量)
ONES **`48734 插件热更`**(库 `EPfZfC9Y` / 模块 其他功能 `Eeme1qVk`)。验证 app 上报的各 RN 插件包版本(Bundle_Version)。数据**不在** app 内,而在 **Zendesk 工单的评论**里——app 提交 feedback 后,工单评论会自动带一段环境信息(`App Version` / `JS_Version` / `Bundle_Version`)。
- 实现:`utils/common/zendesk.helper.ts`,测试 `tests/automation/rn_plugin.test.ts`(纯 API,无 driver,锚点 `[P0][ONES:48734]`)。
- **工单号是变量,绝不写死**。三级解析(`resolveTicketId`):入参 > `env ZENDESK_TICKET_ID` > 上游用例写的共享文件 `reports/.zendesk-ticket.json`
- **上游传值链(确定方案)**:feedback 提交**成功页带工单号** → `captureFeedbackTicketId(driver)`(在 `feedback.helper.ts`)用 `extractTicketId()` 从页面源码抓号、`setSharedTicketId()` 落共享文件 → 插件热更用例 `getTicketRnPlugins()` 自动取用。这就是"app 提交 feedback 后保留工单号传给插件热更"。
- 解析:`parseBundleVersion()` 把首尾相连的 `模块:版本` 串(如 `Home:default 1.0.49.0Scene:...`)拆成 `{模块: 版本}`;`parseDeviceInfo()` 同时抽 App/JS 版本;`extractTicketId()` 兼容「工单号/Ticket/Feedback ID/#数字/兜底长数字」多种页面文案。
- 连接配置在 `.env`(gitignore):`ZENDESK_DOMAIN` / `ZENDESK_EMAIL` / `ZENDESK_TOKEN`(Basic 认证 `email/token:TOKEN`)。
- 无工单号或未配 Zendesk → 用例 **SKIP**(无人值守不阻塞,不静默)。
```bash
# 手动验证(工单号 env 传入,不写死)
ZENDESK_TICKET_ID=<工单号> npx vitest run tests/automation/rn_plugin.test.ts
```
---
## 9. step 级结果回写 ONES正确方法GraphQL mutation
> ⚠️ **必须走 `ones graphql` mutation,不要用 curl 直连 REST `.../cases/update`。**

60
scripts/debug-add-page.ts Normal file
View File

@ -0,0 +1,60 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { navigateToAddPage } from '../utils/common/navigation.helper';
import { sleep } from '../utils/common/element.helper';
import { enterPairingMode } from '../utils/common/relay.helper';
// Meter add:选品类 → 过 Disclaimers → Add Meter 页按继电器 → 向下滑动找设备入口
async function main() {
const device = process.argv[2] || 'meter';
const category = process.argv[3] || 'Meter';
const keyword = process.argv[4] || 'Meter 3M';
const driver = createDriver();
await driver.createSession();
try {
await navigateToAddPage(driver, 'Device');
await sleep(2500);
const dump = async (label: string) => {
const src = await driver.getSource();
const t = Array.from(src.matchAll(/text="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean);
const d = Array.from(src.matchAll(/content-desc="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean);
console.log(`\n=== ${label} ===\n text:`, Array.from(new Set(t)).join(' | '), '\n desc:', Array.from(new Set(d)).join(' | '));
return src;
};
const find = () => driver.findElementRaw('-android uiautomator', `new UiSelector().text("${category}")`);
let el = await find();
for (let i = 0; i < 6 && !el; i++) { await sleep(800); el = await find(); }
for (let i = 0; i < 8 && !el; i++) { await driver.scrollDown(600); await sleep(700); el = await find(); }
console.log(`找到品类 ${category}:`, !!el);
if (!el) return;
await driver.tapElement(el);
await sleep(3000);
// 过一次 Disclaimers 的 Next
let src = await dump('选品类后');
if (src.includes('Disclaimer')) {
const next = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")');
if (next) { await driver.tapElement(next); await sleep(2500); }
}
await dump('Add 页(滑动前)');
// 按继电器配对,设备开始广播
console.log(`\n>>> 按 ${device} 配对...`);
await enterPairingMode(device).catch((e) => console.log('relay 失败:', e.message));
await sleep(3000);
// 向下滑动查找设备入口
for (let i = 0; i <= 5; i++) {
src = await dump(i === 0 ? '配对后(顶部)' : `下滑#${i}`);
if (src.includes(keyword)) { console.log(`\n>>> 找到设备 "${keyword}"`); break; }
await driver.scrollDown(500);
await sleep(1500);
}
} finally {
await driver.destroySession();
}
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

44
scripts/debug-add-shot.ts Normal file
View File

@ -0,0 +1,44 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
import * as fs from 'fs';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { navigateToAddPage } from '../utils/common/navigation.helper';
import { sleep } from '../utils/common/element.helper';
// 导航到 Add <Category> 页并逐页截图(过 Disclaimers),便于人工确认实际 UI
async function main() {
const category = process.argv[2] || 'Meter';
const steps = parseInt(process.argv[3] || '3', 10); // 截多少页(每页点一次 Next)
const driver = createDriver();
await driver.createSession();
const outDir = path.resolve(__dirname, '../reports');
fs.mkdirSync(outDir, { recursive: true });
try {
await navigateToAddPage(driver, 'Device');
await sleep(2500);
const find = () => driver.findElementRaw('-android uiautomator', `new UiSelector().text("${category}")`);
let el = await find();
for (let i = 0; i < 6 && !el; i++) { await sleep(800); el = await find(); }
for (let i = 0; i < 8 && !el; i++) { await driver.scrollDown(600); await sleep(700); el = await find(); }
if (!el) { console.log('未找到品类', category); return; }
await driver.tapElement(el);
await sleep(3000);
for (let i = 1; i <= steps; i++) {
const b64 = await driver.screenshot();
const f = path.join(outDir, `add_${category}_step${i}.png`);
fs.writeFileSync(f, Buffer.from(b64, 'base64'));
const src = await driver.getSource();
const t = Array.from(src.matchAll(/text="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean);
console.log(`step${i} 截图 ${f} | text: ${Array.from(new Set(t)).join(' | ')}`);
const next = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")');
if (!next) { console.log(' 无 Next,停止'); break; }
await driver.tapElement(next);
await sleep(2500);
}
} finally {
await driver.destroySession();
}
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

34
scripts/debug-bot-scan.ts Normal file
View File

@ -0,0 +1,34 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { navigateToAddPage } from '../utils/common/navigation.helper';
import { selectDeviceCategory } from '../utils/common/device-add.helper';
import { sleep } from '../utils/common/element.helper';
// 进 Bot 添加扫描页,dump 扫描列表里多个 Bot 的真实条目(看是否带唯一后缀/MAC)
async function main() {
const category = process.argv[2] || 'Bot';
const driver = createDriver();
await driver.createSession();
try {
await navigateToAddPage(driver, 'Device');
await sleep(2500);
const ok = await selectDeviceCategory(driver, category);
console.log(`选品类 ${category}:`, ok);
// 扫描页多次采样,dump 所有含 Bot 的条目(text + content-desc)
for (let i = 1; i <= 4; i++) {
await sleep(2500);
const src = await driver.getSource();
const t = Array.from(src.matchAll(/text="([^"]{1,40})"/g)).map((m) => m[1]).filter(Boolean);
const d = Array.from(src.matchAll(/content-desc="([^"]{1,40})"/g)).map((m) => m[1]).filter(Boolean);
const all = Array.from(new Set([...t, ...d]));
const bots = all.filter((x) => /bot|[0-9A-Fa-f]{2}$|:|MAC/i.test(x));
console.log(`\n采样#${i} 全部:`, all.join(' | '));
console.log(`采样#${i} 疑似设备/ID:`, bots.join(' | '));
}
} finally {
await driver.destroySession();
}
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

View File

@ -0,0 +1,76 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { navigateToAddPage } from '../utils/common/navigation.helper';
import { selectDeviceCategory } from '../utils/common/device-add.helper';
import { enterPairingMode } from '../utils/common/relay.helper';
import { sleep } from '../utils/common/element.helper';
// 走 curtain 添加,过引导按继电器配对,然后逐页 dump,定位"行程校验"页及其交互
async function main() {
const category = process.argv[2] || 'Curtain';
const relayDevice = process.argv[3] || 'curtain';
const driver = createDriver();
await driver.createSession();
try {
const dump = async (label: string) => {
const src = await driver.getSource();
const t = Array.from(src.matchAll(/text="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean);
const d = Array.from(src.matchAll(/content-desc="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean);
console.log(`\n=== ${label} ===\n text:`, Array.from(new Set(t)).join(' | '), '\n desc:', Array.from(new Set(d)).join(' | '));
return src;
};
await navigateToAddPage(driver, 'Device');
await sleep(2500);
const ok = await selectDeviceCategory(driver, category);
console.log(`选品类 ${category}:`, ok);
if (!ok) return;
// 过 Disclaimers
let src = await dump('选品类后');
if (src.includes('Disclaimer')) {
const next = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")');
if (next) { await driver.tapElement(next); await sleep(2500); }
}
await dump('引导页(按键前)');
// 继电器按开关键 2s 进配对
console.log(`\n>>> 继电器配对 ${relayDevice}...`);
await enterPairingMode(relayDevice).catch((e) => console.log('relay 失败:', e.message));
await sleep(2000);
// 点 Next 连接
const n = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")');
if (n) { await driver.tapElement(n); console.log('点了 Next 连接'); }
// 逐页观察(含连接、选模式、行程校验、命名…),关键字命中即重点标注
for (let i = 1; i <= 20; i++) {
await sleep(3000);
src = await dump(`连接后 第${i}`);
if (/Calibrat|行程|Stroke|stroke|校准|校正|Set.*Position|Travel|Adjust/i.test(src)) {
console.log(`\n>>> 第${i}页 疑似【行程校验】,停下详查`);
break;
}
// 选模式页:选"单边开合"再 Confirm
if (src.includes('Select Mode') || src.includes('Open from one side')) {
const one = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Open from one side")')
|| await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Open from one side")');
if (one) { await driver.tapElement(one); await sleep(1000); console.log(' → 选 Open from one side'); }
const conf = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Confirm")');
if (conf) { await driver.tapElement(conf); console.log(' → Confirm'); }
continue;
}
const nx = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")')
|| await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Got it")')
|| await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Start")')
|| await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Continue")')
|| await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Confirm")');
if (nx) { await driver.tapElement(nx); console.log(' → 点击推进'); }
else console.log(' (无按钮,等待…可能在连接中)');
}
} finally {
await driver.destroySession();
}
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

View File

@ -0,0 +1,43 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { navigateToAddPage } from '../utils/common/navigation.helper';
import { selectDeviceCategory } from '../utils/common/device-add.helper';
import { enterPairingMode } from '../utils/common/relay.helper';
import { sleep, waitForSource } from '../utils/common/element.helper';
// 走 curtain3 全程,到 Auto-Calibrate Close 后逐页 dump,看真实收尾流程
async function main() {
const d = createDriver();
await d.createSession();
try {
const dump = async (l: string) => { const s = await d.getSource(); const t = Array.from(s.matchAll(/text="([^"]{1,60})"/g)).map(m => m[1]).filter(Boolean); console.log(`\n=== ${l} ===\n`, Array.from(new Set(t)).join(' | ')); return s; };
const tapText = async (txt: string) => { const e = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${txt}")`); if (e) { await d.tapElement(e); console.log(' 点', txt); return true; } return false; };
const tapDesc = async (desc: string) => { const e = await d.findElementRaw('-android uiautomator', `new UiSelector().descriptionContains("${desc}")`); if (e) { await d.tapElement(e); console.log(' 勾', desc); return true; } return false; };
await navigateToAddPage(d, 'Device'); await sleep(2500);
await selectDeviceCategory(d, 'Curtain');
let s = await d.getSource();
if (s.includes('Disclaimer')) { await tapText('Next'); await sleep(2500); }
await enterPairingMode('curtain3').catch(() => {});
await sleep(1500); await tapText('Next');
await waitForSource(d, 'Select Mode', 60000).catch(() => {});
if (!(await tapDesc('Open from one side'))) await tapText('Open from one side');
await sleep(1000); await tapText('Confirm');
await waitForSource(d, 'Install', 30000).catch(() => {});
await tapText('Install');
await waitForSource(d, 'rail type', 15000).catch(() => {}); await tapText('Skip');
await waitForSource(d, 'Custom Calibration', 15000).catch(() => {}); await tapText('Custom Calibration');
await waitForSource(d, 'Confirm Orientation', 15000).catch(() => {}); await tapDesc('facing inwards'); await sleep(800); await tapText('Next');
await waitForSource(d, 'fully closed', 15000).catch(() => {}); await tapDesc('fully closed'); await sleep(800); await tapText('Start Calibration');
await waitForSource(d, 'Magnet Detection', 15000).catch(() => {}); await tapText('Skip magnet detection'); await sleep(1500); await tapText('Skip');
await waitForSource(d, 'Move left', 20000).catch(() => {});
await tapText('Move left'); console.log('等15s...'); await sleep(15000);
await tapText('Auto-Calibrate Close');
// ↓↓↓ 重点:Auto-Calibrate Close 之后逐页观察
for (let i = 1; i <= 12; i++) { await sleep(3000); s = await dump(`Auto-Close 后 第${i}`); const left = await d.findElementsRaw('id', 'com.theswitchbot.switchbot:id/leftCard'); if (left.length) { console.log(`>>> 回首页 卡片=${left.length}`); break; } }
} finally { await d.destroySession(); }
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

View File

@ -0,0 +1,31 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { navigateToAddPage } from '../utils/common/navigation.helper';
import { sleep } from '../utils/common/element.helper';
// 下滑添加页,逐屏找含 "Remote" 的文案,定位标准 Remote 入口的确切文字与位置
async function main() {
const needle = process.argv[2] || 'Remote';
const driver = createDriver();
await driver.createSession();
try {
await navigateToAddPage(driver, 'Device');
await sleep(2500);
for (let i = 0; i <= 10; i++) {
const src = await driver.getSource();
const hits = Array.from(src.matchAll(/text="([^"]*)"/g)).map((m) => m[1])
.filter((t) => t && t.toLowerCase().includes(needle.toLowerCase()));
console.log(`屏#${i} 命中"${needle}":`, hits.length ? Array.from(new Set(hits)).join(' | ') : '(无)');
// 命中精确 needle 就停
const exact = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${needle}")`);
if (exact) { console.log(`>>> 屏#${i} 找到精确 text("${needle}")`); break; }
await driver.scrollDown(700);
await sleep(1200);
}
} finally {
await driver.destroySession();
}
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

View File

@ -0,0 +1,43 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { ensureHomeTab } from '../utils/common/navigation.helper';
import { sleep } from '../utils/common/element.helper';
// 探索:首页房间标签 + 长按设备卡进入删除模式后出现什么
async function main() {
const cardText = process.argv[2] || 'Meter';
const driver = createDriver();
await driver.createSession();
try {
await ensureHomeTab(driver);
await driver.dismissPopupIfPresent();
await sleep(1500);
const dump = async (label: string) => {
const src = await driver.getSource();
const t = Array.from(src.matchAll(/text="([^"]{1,40})"/g)).map((m) => m[1]).filter(Boolean);
const d = Array.from(src.matchAll(/content-desc="([^"]{1,40})"/g)).map((m) => m[1]).filter(Boolean);
console.log(`\n=== ${label} ===\n text:`, Array.from(new Set(t)).join(' | '), '\n desc:', Array.from(new Set(d)).join(' | '));
};
// 房间标签
const rooms = await driver.findElementsRaw('id', 'com.theswitchbot.switchbot:id/clSelectRoom');
console.log('房间标签数:', rooms.length);
for (const r of rooms) { const tx = await driver.getElementAttribute(r, 'text').catch(() => ''); if (tx) console.log(' 房间:', tx); }
await dump('首页(长按前)');
// 找设备卡 → 长按其中心
let card = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${cardText}")`);
if (!card) { console.log(`未找到含"${cardText}"的卡片`); return; }
const rect = await driver.getElementRect(card);
console.log(`长按 "${cardText}" 卡片中心 (${Math.round(rect.x + rect.width / 2)}, ${Math.round(rect.y + rect.height / 2)})`);
await driver.longPress(Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2), 1.2);
await sleep(2500);
await dump('长按后(删除/管理模式?)');
} finally {
await driver.destroySession();
}
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

View File

@ -0,0 +1,46 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { navigateToAddPage } from '../utils/common/navigation.helper';
import { sleep } from '../utils/common/element.helper';
// 抓取 App「添加设备」完整产品目录(产品看板),输出全部单品设备名
async function main() {
const driver = createDriver();
await driver.createSession();
try {
await navigateToAddPage(driver, 'Device');
await sleep(2500);
// 点 Show More 展开全部品类
const more = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Show More")');
if (more) { await driver.tapElement(more); await sleep(1500); }
const all = new Set<string>();
// 非产品的 UI 文案/区块标题,过滤掉
const NON_PRODUCT = new Set([
'Add Device', 'Scanning for Bluetooth devices nearby...', 'Show More', 'Show less',
'Add Manually', 'Home Automation', 'Smart Home Appliances', 'Sensor', 'Camera',
'Lock', 'Light', 'Curtain', 'Hub', 'Robot Vacuum', 'Others', 'Smart Switch',
'Climate', 'Security', 'Lighting', 'Cleaning',
]);
for (let i = 0; i < 16; i++) {
const src = await driver.getSource();
for (const m of src.matchAll(/text="([^"]{2,45})"/g)) {
const t = m[1].trim();
if (!t) continue;
if (/^\d+$/.test(t)) continue; // 纯数字(角标计数)
if (NON_PRODUCT.has(t)) continue;
all.add(t);
}
await driver.scrollDown(650);
await sleep(900);
}
const list = Array.from(all).sort();
console.log('=== 产品目录(共 ' + list.length + ' 项,已过滤UI/区块标题)===');
list.forEach((x, i) => console.log(`${String(i + 1).padStart(3)}. ${x}`));
} finally {
await driver.destroySession();
}
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

9
scripts/gen-combined.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* reports/.results.json ( + + //)
* soak (SOAK_MODE .results.json vitest run )
* npx ts-node scripts/gen-combined.ts <报告名前缀>
*/
import { TestReporter } from '../utils/test-reporter';
const name = process.argv[2] || 'Combined';
const out = TestReporter.generateCombinedReport(name);
console.log('合并报告:', out || '(无结果)');

View File

@ -42,6 +42,13 @@ const CATEGORY_MODULES = new Set([
'Yb6hgZ9T', // 扫地机类
]);
// 平台级模块(账号/房间/消息/场景/安装等)—— 同属 P0 必测,非设备品类
const PLATFORM_MODULES = new Set([
'Czs8zN3v', // 我的(账号/房间/feedback)
'Eeme1qVk', // 其他功能(消息中心/插件热更/覆盖安装/场景操作)
'PJpV3Qz2', // 平台功能(第三方操作)
]);
// 关键字 -> 品类(= 测试目录名, snake_case)。按最长命中优先匹配。
const KEYWORD_TO_CATEGORY: [string, string][] = [
['relay switch', 'plug'], ['garage', 'plug'], ['plug mini', 'plug'], ['plugmini', 'plug'], ['eu plug', 'plug'], ['plug', 'plug'],
@ -101,7 +108,7 @@ function shortDesc(s: string): string {
}
interface AddRow {
kind: 'add' | 'feature';
kind: 'add' | 'feature' | 'platform';
ones: number;
name: string;
cat: string;
@ -166,6 +173,22 @@ function main() {
});
}
// 1b) platform: 平台模块内、在计划内的 case(P0,非设备)
for (const c of libCases) {
if (!PLATFORM_MODULES.has(c.module_uuid)) continue;
if (!planNums.has(c.number)) continue;
rows.push({
kind: 'platform',
ones: c.number,
name: c.name,
cat: 'platform',
device: '',
file: '', // 多为新建脚本;场景操作可指向 tests/automation
testName: `[P0][ONES:${c.number}] ${c.name}`,
status: 'todo',
});
}
// 2) ctrl: 两条协议超级用例的每个 step
for (const { number, proto } of CTRL_CASES) {
const found = runOnes(`testcase case search --key ${number}`).cases || [];
@ -216,9 +239,11 @@ function argVal(argv: string[], flag: string): string | undefined {
function printSummary(rows: Row[], na: string[], plan: string) {
const by = (k: string) => rows.filter((r) => r.kind === k);
const add = by('add'), feat = by('feature'), ctrl = by('ctrl') as CtrlRow[];
const platform = by('platform');
console.log(`\n=== 必测项覆盖摘要 (plan ${plan}) ===`);
console.log(`add(添加) : ${add.length}`);
console.log(`feature(功能): ${feat.length}`);
console.log(`platform(平台): ${platform.length}`);
console.log(`ctrl(控制step): ${ctrl.length} (ble ${ctrl.filter((r) => r.proto === 'ble').length} / wifi ${ctrl.filter((r) => r.proto === 'wifi').length})`);
const catCount: Record<string, number> = {};
for (const r of rows) catCount[r.cat || '∅'] = (catCount[r.cat || '∅'] || 0) + 1;
@ -234,7 +259,7 @@ function renderManifest(rows: Row[], plan: string): string {
export type MustTestItem =
| {
kind: 'add' | 'feature';
kind: 'add' | 'feature' | 'platform';
ones: number;
name: string;
cat: string;

View File

@ -0,0 +1,86 @@
#!/bin/bash
# iOS 夜间添加稳定性 soak:循环【清空设备 → 逐个添加】到次日 09:00。
# 关键:iOS 走 WDA(8100),WDA 易掉 → 每轮检查并自动重起;每个设备添加前 terminate/launch 把 app 复位到首页
# (iOS navigateToAddPage 无回首页恢复)。计数沿用 SKIP 护栏(SKIP 不计 PASS)。
# nohup caffeinate -dimsu bash scripts/overnight-soak-ios.sh > reports/soak-ios.out 2>&1 &
set -u
cd "$(dirname "$0")/.."
export PLATFORM=ios
export SOAK_MODE=1
UDID=00008110-001A34303AE9801E
BUNDLE=com.wohand.wohand
DEADLINE=$(date -v+1d -v9H -v0M -v0S +%s 2>/dev/null) || DEADLINE=$(($(date +%s) + 50400))
TS=$(date +%Y%m%d-%H%M%S)
mkdir -p reports
LOG="reports/soak-ios-$TS.log"
DETAIL="reports/soak-ios-$TS.detail.log"
DEVICES=(
"meter|tests/meter/meter_connect.test.ts|"
"remote|tests/remote/remote_connect.test.ts|"
"hub|tests/hub/hub_connect.test.ts|export HUB_DEVICE='HubMini 9I'"
)
PASS=(); FAIL=(); SKIP=()
for i in "${!DEVICES[@]}"; do PASS[$i]=0; FAIL[$i]=0; SKIP[$i]=0; done
log(){ echo "[$(date '+%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
wda_sid(){ curl -s --max-time 5 http://localhost:8100/status 2>/dev/null | grep -oE '[0-9A-F]{8}-[0-9A-F-]{27,}' | head -1; }
wda_ready(){ curl -s --max-time 5 http://localhost:8100/status 2>/dev/null | grep -q '"ready" : true'; }
ensure_wda(){
if wda_ready; then return 0; fi
log " WDA 不在,重起 WDA..."
pkill -f iproxy 2>/dev/null; sleep 1
curl -s --max-time 280 -X POST http://127.0.0.1:4723/session -H 'Content-Type: application/json' \
-d "{\"capabilities\":{\"alwaysMatch\":{\"platformName\":\"iOS\",\"appium:automationName\":\"XCUITest\",\"appium:udid\":\"$UDID\",\"appium:platformVersion\":\"26.5\",\"appium:deviceName\":\"iPhone\",\"appium:bundleId\":\"$BUNDLE\",\"appium:wdaLocalPort\":8100,\"appium:noReset\":true,\"appium:newCommandTimeout\":3600}}}" >>"$DETAIL" 2>&1
sleep 3
wda_ready && log " WDA 已就绪" || log " WDA 重起失败(本轮可能全失败)"
}
app_home(){ local sid; sid=$(wda_sid); [ -z "$sid" ] && return 0
curl -s --max-time 15 -X POST "http://localhost:8100/session/$sid/wda/apps/terminate" -H 'Content-Type: application/json' -d "{\"bundleId\":\"$BUNDLE\"}" >/dev/null 2>&1
sleep 1
curl -s --max-time 15 -X POST "http://localhost:8100/session/$sid/wda/apps/launch" -H 'Content-Type: application/json' -d "{\"bundleId\":\"$BUNDLE\"}" >/dev/null 2>&1
sleep 2
}
names=""; for spec in "${DEVICES[@]}"; do names+="${spec%%|*} "; done
log "===== iOS SOAK 开始,截止 $(date -r $DEADLINE '+%m-%d %H:%M' 2>/dev/null) ; 设备: $names====="
rm -f reports/.results.json
ensure_wda
cycle=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
cycle=$((cycle+1))
log "----- Cycle $cycle -----"
ensure_wda
app_home
if RESET_DEVICES=1 npx ts-node scripts/reset-devices.ts >>"$DETAIL" 2>&1; then log " reset 完成"; else log " reset 异常(继续)"; fi
for i in "${!DEVICES[@]}"; do
spec="${DEVICES[$i]}"; name="${spec%%|*}"; rest="${spec#*|}"; file="${rest%%|*}"; envs="${rest#*|}"
echo "[$(date '+%H:%M:%S')] >>> Cycle $cycle $name" >>"$DETAIL"
ensure_wda; app_home # 每个设备添加前复位到首页(iOS 导航无回首页恢复)
runlog=$(mktemp)
( eval "$envs"; npx vitest run "$file" ) >"$runlog" 2>&1; rc=$?
cat "$runlog" >>"$DETAIL"
if grep -q "\[SKIP\]" "$runlog"; then
SKIP[$i]=$(( SKIP[$i] + 1 )); log " $name SKIP(设备已存在 → 不计通过)"
elif [ $rc -eq 0 ] && grep -q "\[PASS\]" "$runlog"; then
PASS[$i]=$(( PASS[$i] + 1 )); log " $name PASS"
else
FAIL[$i]=$(( FAIL[$i] + 1 )); log " $name FAIL (见 $DETAIL)"
fi
rm -f "$runlog"
done
tally=""; for i in "${!DEVICES[@]}"; do n="${DEVICES[$i]%%|*}"; tot=$(( PASS[$i] + FAIL[$i] + SKIP[$i] )); tally+="$n ${PASS[$i]}/${tot}(skip${SKIP[$i]}) "; done
log " 累计通过率(pass/total,skip另计): $tally"
done
log "===== iOS SOAK 结束,共 $cycle 轮 ====="
final=""; for i in "${!DEVICES[@]}"; do n="${DEVICES[$i]%%|*}"; tot=$(( PASS[$i] + FAIL[$i] + SKIP[$i] )); final+="$n=${PASS[$i]}/${tot}(skip${SKIP[$i]}) "; done
log "最终: $final"
log "生成汇总报告..."
npx ts-node scripts/soak-report.ts "ios-$TS" >>"$DETAIL" 2>&1 && log "时间线汇总: reports/soak-report-ios-$TS.*" || log "时间线汇总生成失败"
npx ts-node scripts/gen-combined.ts "SoakIOS_$TS" >>"$DETAIL" 2>&1 && log "逐用例报告: reports/SoakIOS_${TS}_*.html" || log "逐用例报告生成失败"

78
scripts/overnight-soak.sh Normal file
View File

@ -0,0 +1,78 @@
#!/bin/bash
# 夜间添加稳定性 soak:循环【清空所有设备 → 逐个添加】直到次日 09:00,记录每设备 通过/失败 计数。
# 用法(后台不挂断运行):
# nohup bash scripts/overnight-soak.sh > reports/soak.out 2>&1 &
# 设备清单见 DEVICES;每项 "名称|测试文件|额外ENV(eval形式export语句,可含空格值)"。
# 兼容 macOS 自带 bash 3.2(用索引数组,不用关联数组)。
set -u
cd "$(dirname "$0")/.."
export PLATFORM=android
export SOAK_MODE=1 # 让各次 vitest run 累积结果到 reports/.results.json,全程结束统一出一份逐用例+截图报告
# 截止时间 = 次日 09:00(macOS date 语法)
DEADLINE=$(date -v+1d -v9H -v0M -v0S +%s 2>/dev/null) || DEADLINE=$(($(date +%s) + 50400))
TS=$(date +%Y%m%d-%H%M%S)
mkdir -p reports
LOG="reports/soak-$TS.log"
DETAIL="reports/soak-$TS.detail.log"
DEVICES=(
"meter|tests/meter/meter_connect.test.ts|"
"outdoor_meter|tests/meter/outdoor_meter_connect.test.ts|"
"remote|tests/remote/remote_connect.test.ts|"
"curtain3|tests/curtain/curtain_connect.test.ts|export CURTAIN_DEVICE='Curtain3 2B'; export CURTAIN_CALIBRATE=1"
)
# 索引数组(bash 3.2 兼容)
PASS=(); FAIL=(); SKIP=()
for i in "${!DEVICES[@]}"; do PASS[$i]=0; FAIL[$i]=0; SKIP[$i]=0; done
log(){ echo "[$(date '+%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
names=""; for spec in "${DEVICES[@]}"; do names+="${spec%%|*} "; done
log "===== SOAK 开始,截止 $(date -r $DEADLINE '+%m-%d %H:%M' 2>/dev/null) ; 设备: $names====="
rm -f reports/.results.json # 全程开始前清一次,之后累积所有轮次的逐用例结果
cycle=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
cycle=$((cycle+1))
log "----- Cycle $cycle -----"
if RESET_DEVICES=1 npx ts-node scripts/reset-devices.ts >>"$DETAIL" 2>&1; then
log " reset 完成"
else
log " reset 异常(继续)"
fi
for i in "${!DEVICES[@]}"; do
spec="${DEVICES[$i]}"
name="${spec%%|*}"; rest="${spec#*|}"; file="${rest%%|*}"; envs="${rest#*|}"
echo "[$(date '+%H:%M:%S')] >>> Cycle $cycle $name" >>"$DETAIL"
runlog=$(mktemp)
( eval "$envs"; npx vitest run "$file" ) >"$runlog" 2>&1; rc=$?
cat "$runlog" >>"$DETAIL"
# 按实际结果计数:SKIP(设备已存在=reset 未清干净)单列,绝不计入 PASS,以免通过率被灌水
if grep -q "\[SKIP\]" "$runlog"; then
SKIP[$i]=$(( SKIP[$i] + 1 )); log " $name SKIP(设备已存在,reset 未清干净 → 不计通过)"
elif [ $rc -eq 0 ] && grep -q "\[PASS\]" "$runlog"; then
PASS[$i]=$(( PASS[$i] + 1 )); log " $name PASS"
else
FAIL[$i]=$(( FAIL[$i] + 1 )); log " $name FAIL (见 $DETAIL)"
fi
rm -f "$runlog"
done
tally=""
for i in "${!DEVICES[@]}"; do n="${DEVICES[$i]%%|*}"; tot=$(( PASS[$i] + FAIL[$i] + SKIP[$i] )); tally+="$n ${PASS[$i]}/${tot}(skip${SKIP[$i]}) "; done
log " 累计通过率(pass/total,skip另计): $tally"
done
log "===== SOAK 结束,共 $cycle 轮 ====="
final=""; for i in "${!DEVICES[@]}"; do n="${DEVICES[$i]%%|*}"; tot=$(( PASS[$i] + FAIL[$i] + SKIP[$i] )); final+="$n=${PASS[$i]}/${tot}(skip${SKIP[$i]}) "; done
log "最终: $final"
# 生成单份汇总报告(结论/问题/解决方案)
log "生成汇总报告..."
npx ts-node scripts/soak-report.ts "$TS" >>"$DETAIL" 2>&1 && log "时间线汇总: reports/soak-report-$TS.md / .html" || log "时间线汇总生成失败(见 $DETAIL)"
# 逐用例 + 失败截图的整体报告(参照 combined 报告格式)
npx ts-node scripts/gen-combined.ts "Soak_$TS" >>"$DETAIL" 2>&1 && log "逐用例报告: reports/Soak_${TS}_*.html" || log "逐用例报告生成失败(见 $DETAIL)"

View File

@ -0,0 +1,49 @@
/**
* (=),
* 20 寿,//
* npx ts-node scripts/relay-press-report.ts
* 数据来源:test-plan/relay-press-counts.json( SerialController )
*/
import * as fs from 'fs';
import * as path from 'path';
import { RELAY_MAP } from '../config/relay.config';
const FILE = path.resolve(__dirname, '../test-plan/relay-press-counts.json');
const LIFE = 200000;
// 反查:通道号 → "设备.按键"(取 android 通道;两端不同时也显示 ios)
const chLabel: Record<number, string> = {};
for (const [dev, cfg] of Object.entries(RELAY_MAP)) {
for (const [btn, spec] of Object.entries(cfg.buttons)) {
const chs = typeof spec === 'number' ? [spec] : [spec.android, spec.ios].filter((x): x is number => x != null);
for (const ch of chs) chLabel[ch] = `${dev}.${btn}`;
}
}
const data: Record<string, Record<string, number>> = fs.existsSync(FILE)
? JSON.parse(fs.readFileSync(FILE, 'utf-8')) : {};
if (!Object.keys(data).length) {
console.log('(暂无点击计数;跑过添加/继电器操作后才会有。文件:' + FILE + ')');
process.exit(0);
}
for (const [board, chs] of Object.entries(data)) {
console.log(`\n=== 开发板 ${board} ===`);
console.log('通道'.padEnd(6) + '设备.按键'.padEnd(22) + '点击次数'.padEnd(12) + '寿命占比'.padEnd(10) + '剩余');
const entries = Object.entries(chs).map(([ch, c]) => [Number(ch), c] as [number, number]).sort((a, b) => b[1] - a[1]);
let total = 0;
for (const [ch, cnt] of entries) {
total += cnt;
const pct = (cnt / LIFE * 100).toFixed(2) + '%';
const warn = cnt >= LIFE * 0.9 ? ' ⚠已超90%寿命' : cnt >= LIFE * 0.8 ? ' ⚠接近寿命' : '';
console.log(
`ch${ch}`.padEnd(6) +
(chLabel[ch] || '-').padEnd(22) +
String(cnt).padEnd(12) +
pct.padEnd(10) +
String(LIFE - cnt) + warn
);
}
console.log(`板内累计点击: ${total}`);
}

106
scripts/relay.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* //,便线
*
* (/ .env SERIAL_PORT/SERIAL_BAUD):
* npx ts-node scripts/relay.ts probe # ()
* npx ts-node scripts/relay.ts status # 64
* npx ts-node scripts/relay.ts pair remote # ( config )
* npx ts-node scripts/relay.ts pair meter --hold 2500 # ,
* npx ts-node scripts/relay.ts press remote convex --hold 800 #
* npx ts-node scripts/relay.ts pulse 34,35 --hold 3000 #
* npx ts-node scripts/relay.ts on 34 / off 34 # /
* npx ts-node scripts/relay.ts raw 55C8210155 # 16
*/
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { SerialController } from '../utils/common/serial_controller';
import { triggerRelayAction, pressButton } from '../utils/common/relay.helper';
function flag(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
/** 取位置参数(剔除 --flag 及其紧随的值)。 */
function positionals(): string[] {
const argv = process.argv.slice(2);
const out: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
i++; // 跳过该 flag 的值
continue;
}
out.push(argv[i]);
}
return out;
}
async function main() {
const [cmd, a, b] = positionals();
const hold = flag('hold') ? parseInt(flag('hold') as string, 10) : undefined;
switch (cmd) {
case 'probe': {
const c = new SerialController();
await c.open();
console.log(await c.queryVersion());
await c.close();
break;
}
case 'status': {
const c = new SerialController();
await c.open();
console.log('status:', (await c.readStatus()).toString('hex').toUpperCase());
await c.close();
break;
}
case 'pair': {
if (!a) throw new Error('用法: pair <device> [--hold ms]');
await triggerRelayAction(a, 'pairing', hold ? { holdMs: hold } : {});
break;
}
case 'press': {
if (!a || !b) throw new Error('用法: press <device> <button> [--hold ms]');
await pressButton(a, b, hold ?? 500);
break;
}
case 'pulse': {
if (!a) throw new Error('用法: pulse <ch1,ch2,...> [--hold ms]');
const chs = a.split(',').map((s) => parseInt(s, 10));
const c = new SerialController();
await c.open();
await c.pulseChannels(chs, hold ?? 500);
console.log(`脉冲通道[${chs.join(',')}] ${hold ?? 500}ms 完成`);
await c.close();
break;
}
case 'on':
case 'off': {
if (!a) throw new Error(`用法: ${cmd} <ch>`);
const c = new SerialController();
await c.open();
const resp = cmd === 'on' ? await c.relayOn(parseInt(a, 10)) : await c.relayOff(parseInt(a, 10));
console.log(`${cmd} ${a}:`, resp.toString('hex').toUpperCase());
await c.close();
break;
}
case 'raw': {
if (!a) throw new Error('用法: raw <hex,如 55C8210155>');
const bytes = (a.match(/.{1,2}/g) || []).map((h) => parseInt(h, 16));
const c = new SerialController();
await c.open();
console.log('resp:', (await c.send(bytes)).toString('hex').toUpperCase());
await c.close();
break;
}
default:
console.log('命令: probe | status | pair <dev> | press <dev> <btn> | pulse <chs> | on <ch> | off <ch> | raw <hex> [--hold ms]');
}
}
main().catch((e) => {
console.error('错误:', e.message);
process.exit(1);
});

34
scripts/reset-devices.ts Normal file
View File

@ -0,0 +1,34 @@
/**
* 必测执行前置:清空当前平台账号下****, connect
* 删除方式:首页长按卡片 Delete (,)
*
* 安全开关:必须 RESET_DEVICES=1 ()按平台:PLATFORM=android/ios
* RESET_DEVICES=1 PLATFORM=android npx ts-node scripts/reset-devices.ts
*/
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { createDriver } from '../drivers/factory';
import { removeAllDevices, homepageHasDeviceCards } from '../utils/common';
async function main() {
if (process.env.RESET_DEVICES !== '1') {
console.error('已阻止:这是删除所有设备的操作。确认后用 RESET_DEVICES=1 重跑。');
process.exit(2);
}
const driver = createDriver();
await driver.createSession();
try {
const removed = await removeAllDevices(driver);
console.log(`\n[reset] 平台=${process.env.PLATFORM || 'ios'} 删除 ${removed.length} 个设备:`, removed.join(', ') || '(无)');
// 删后校验:首页若仍有设备卡 → reset 没清干净(如批量删卡失败),exit 1 让上层(soak)记为"reset 异常",
// 避免后续 connect 走 SKIP 把通过率灌水。
if (await homepageHasDeviceCards(driver)) {
console.error('[reset] 警告:首页仍有设备卡,未清空干净!');
process.exitCode = 1;
}
} finally {
await driver.destroySession();
}
}
main().catch((e) => { console.error('ERR:', e.message); process.exit(1); });

270
scripts/soak-report.ts Normal file
View File

@ -0,0 +1,270 @@
/**
* Soak soak ****(Markdown)
* :概览线()
*
* :
* npx ts-node scripts/soak-report.ts [soak日志时间戳, 20260602-191820] #
* 输出:reports/soak-report-<TS>.md
* (overnight-soak.sh )
*/
import * as fs from 'fs';
import * as path from 'path';
const reportsDir = path.resolve(__dirname, '../reports');
// 失败模式 → 说明 + 解决方案(知识库)
const FAILURE_KB: Record<string, { label: string; solution: string }> = {
navFail: {
label: '导航到添加页失败(找不到首页"+")',
solution: '残留弹框/卡死页挡住"+"。已修:navigateToAddPage 找不到"+"时 force-stop 重启 App 兜底(最多3次);dismissPopupIfPresent 识别"连接失败"弹框;removeAllDevices 开头重启清场。',
},
pairNotConnected: {
label: '按了继电器但未连上(配对未触发)',
solution: '物理配对触发不稳(如 remote 凸+凹同时按 3s,~29% 漏)。已修:addDeviceWithSerialPairing 配对未发现设备就重按重试(pairRetries=3,判据=连接关键字或设备名命中)。',
},
connTimeout: { label: '连接超时', solution: '检查蓝牙/设备环境;必要时延长 waitForConnection 或重按重试。' },
noNext: { label: '引导页未找到 Next', solution: '核对引导页文案/时序;tapNext 增加等待或重试。' },
catFail: { label: '品类选择失败', solution: '已用 UiScrollable.scrollIntoView 直达深层品类 + 重启兜底。' },
calibFail: { label: '行程校验异常(Something went wrong)', solution: 'curtain3 校准需设备装真实导轨;Auto-Calibrate Close 后重试点 Finish 已加。' },
other: { label: '其它/未分类失败', solution: '查 detail 日志对应 Cycle 段落定位。' },
};
interface DevStat { pass: number; total: number; modes: Record<string, number>; }
function classifySegment(seg: string): string {
if (/cannot navigate to Add Device|addBto not found/.test(seg)) return 'navFail';
if (/cannot select category|no .* option/.test(seg)) return 'catFail';
if (/connection timeout/.test(seg)) return 'connTimeout';
if (/引导页未找到 Next|未找到 Next/.test(seg)) return 'noNext';
if (/Something went wrong/.test(seg)) return 'calibFail';
// 跑了继电器但最终失败且没连上
if (/relay: .*pairing/.test(seg) && /✗|expected false to be true/.test(seg)) return 'pairNotConnected';
return 'other';
}
function main() {
const ts = process.argv[2] || latestTs();
if (!ts) { console.error('未找到 soak 日志'); process.exit(1); }
const mainLog = path.join(reportsDir, `soak-${ts}.log`);
const detailLog = path.join(reportsDir, `soak-${ts}.detail.log`);
if (!fs.existsSync(mainLog)) { console.error('找不到', mainLog); process.exit(1); }
const mainText = fs.readFileSync(mainLog, 'utf-8');
const detailText = fs.existsSync(detailLog) ? fs.readFileSync(detailLog, 'utf-8') : '';
// 1) 主日志:时间范围、轮次、每设备 PASS/FAIL 序列
const lines = mainText.split('\n');
const stamps = lines.map((l) => (l.match(/^\[(\d\d-\d\d \d\d:\d\d:\d\d)\]/) || [])[1]).filter(Boolean) as string[];
const startT = stamps[0] || '?', endT = stamps[stamps.length - 1] || '?';
const cycles = (mainText.match(/----- Cycle \d+ -----/g) || []).length;
const devOrder: string[] = [];
const stats: Record<string, DevStat> = {};
// 每轮结果(用于死区检测):cycle -> {dev: pass/fail}
const perCycle: Record<number, Record<string, boolean>> = {};
let curCycle = 0;
for (const l of lines) {
const cm = l.match(/----- Cycle (\d+) -----/);
if (cm) { curCycle = +cm[1]; perCycle[curCycle] = {}; continue; }
const rm = l.match(/^\[.*\] (\w+) (PASS|FAIL)/);
if (rm) {
const [, dev, res] = rm;
if (!stats[dev]) { stats[dev] = { pass: 0, total: 0, modes: {} }; devOrder.push(dev); }
stats[dev].total++; if (res === 'PASS') stats[dev].pass++;
if (perCycle[curCycle]) perCycle[curCycle][dev] = res === 'PASS';
}
}
// 2) detail 日志:按 Cycle/device 段落归类失败模式
if (detailText) {
const segRe = />>> Cycle \d+\s+(\w+)/g;
const idxs: { dev: string; pos: number }[] = [];
let m;
while ((m = segRe.exec(detailText)) !== null) idxs.push({ dev: m[1], pos: m.index });
for (let i = 0; i < idxs.length; i++) {
const seg = detailText.slice(idxs[i].pos, i + 1 < idxs.length ? idxs[i + 1].pos : undefined);
if (/✗|FAIL tests\/|expected false to be true/.test(seg) && !/✓ \[PASS\]/.test(seg.slice(0, 400))) {
const dev = idxs[i].dev;
if (!stats[dev]) { stats[dev] = { pass: 0, total: 0, modes: {} }; devOrder.push(dev); }
const mode = classifySegment(seg);
stats[dev].modes[mode] = (stats[dev].modes[mode] || 0) + 1;
}
}
}
// 3) 死区:连续"全设备 FAIL"的轮次
const deadCycles: number[] = [];
for (const c of Object.keys(perCycle).map(Number).sort((a, b) => a - b)) {
const r = perCycle[c]; const vals = Object.values(r);
if (vals.length && vals.every((v) => v === false)) deadCycles.push(c);
}
// 4) 生成报告
// 剔除死区后的"健康期"成功率(更反映设备本身可靠性)
const deadSet = new Set(deadCycles);
const healthy: Record<string, { pass: number; total: number }> = {};
for (const dev of devOrder) healthy[dev] = { pass: 0, total: 0 };
for (const c of Object.keys(perCycle).map(Number)) {
if (deadSet.has(c)) continue;
for (const dev of Object.keys(perCycle[c])) {
if (!healthy[dev]) healthy[dev] = { pass: 0, total: 0 };
healthy[dev].total++;
if (perCycle[c][dev]) healthy[dev].pass++;
}
}
const report = render({ ts, startT, endT, cycles, devOrder, stats, healthy, deadCycles, perCycleCount: Object.keys(perCycle).length });
const out = path.join(reportsDir, `soak-report-${ts}.md`);
fs.writeFileSync(out, report, 'utf-8');
const html = renderHtml({ ts, startT, endT, cycles, devOrder, stats, healthy, deadCycles });
const outHtml = path.join(reportsDir, `soak-report-${ts}.html`);
fs.writeFileSync(outHtml, html, 'utf-8');
console.log('已生成汇总报告:');
console.log(' Markdown:', out);
console.log(' HTML :', outHtml);
console.log('\n' + report);
}
function pct(p: number, t: number) { return t ? ((p / t) * 100).toFixed(1) + '%' : '-'; }
function render(d: any): string {
const { ts, startT, endT, cycles, devOrder, stats, healthy, deadCycles } = d;
let r = `# 夜间添加稳定性 Soak 报告\n\n`;
r += `- 运行:${startT}${endT} | 轮次:${cycles} | 日志:soak-${ts}\n\n`;
r += `## 一、各设备成功率\n\n| 设备 | 通过/总数 | 成功率 | 剔除死区后 |\n|---|---|---|---|\n`;
for (const dev of devOrder) {
const s = stats[dev]; const h = healthy[dev] || { pass: 0, total: 0 };
r += `| ${dev} | ${s.pass}/${s.total} | ${pct(s.pass, s.total)} | ${pct(h.pass, h.total)} (${h.pass}/${h.total}) |\n`;
}
r += `\n> "剔除死区后"= 排除整轮全失败的卡死轮次后,设备本身的真实可靠性。\n`;
r += `\n## 二、失败模式分布\n\n| 设备 | 失败模式 | 次数 |\n|---|---|---|\n`;
for (const dev of devOrder) {
const modes = stats[dev].modes; const keys = Object.keys(modes).sort((a, b) => modes[b] - modes[a]);
if (!keys.length) { r += `| ${dev} | (无失败明细) | - |\n`; continue; }
for (const k of keys) r += `| ${dev} | ${FAILURE_KB[k]?.label || k} | ${modes[k]} |\n`;
}
// 死区
r += `\n## 三、失败时间线(死区检测)\n\n`;
if (deadCycles.length) {
r += `检测到 **${deadCycles.length}** 个"整轮全失败"轮次(全设备同时失败,通常是 App 卡死未恢复):Cycle ${deadCycles[0]} ~ ${deadCycles[deadCycles.length - 1]}\n`;
r += `> 这类连续死区会严重拉低整体成功率,且多为单点卡死(如未处理的弹框)引发的级联。\n`;
} else { r += `未检测到连续整轮死区。\n`; }
// 结论
r += `\n## 四、结论\n\n`;
const hrate = (dv: string) => { const h = healthy[dv] || { pass: 0, total: 0 }; return h.total ? h.pass / h.total : 0; };
const stableDevs = devOrder.filter((dv: string) => hrate(dv) >= 0.95);
const weak = devOrder.filter((dv: string) => (healthy[dv]?.total || 0) > 0 && hrate(dv) < 0.85);
if (deadCycles.length) r += `- 本轮存在 ${deadCycles.length} 个整轮死区,是整体成功率偏低的**主因**;按"剔除死区后"看设备真实可靠性更准。\n`;
if (stableDevs.length) r += `- 稳定设备(剔除死区后 ≥95%):${stableDevs.join('、')}\n`;
if (weak.length) r += `- 偏脆设备(剔除死区后 <85%):${weak.join('、')} —— 需针对性优化其配对/连接。\n`;
if (!weak.length && !deadCycles.length) r += `- 各设备成功率均良好,添加流程稳定。\n`;
// 问题 + 解决方案(基于实际命中的失败模式)
const hitModes = new Set<string>();
for (const dev of devOrder) Object.keys(stats[dev].modes).forEach((k) => hitModes.add(k));
r += `\n## 五、存在的问题\n\n`;
if (deadCycles.length) r += `1. **整轮死区**:连续 ${deadCycles.length} 轮全失败,单点卡死引发级联(无人值守缺重启级恢复时尤甚)。\n`;
let i = deadCycles.length ? 2 : 1;
for (const k of hitModes) { if (k === 'navFail' && deadCycles.length) continue; r += `${i++}. **${FAILURE_KB[k]?.label || k}**\n`; }
if (i === 1) r += `(无显著问题)\n`;
r += `\n## 六、解决方案\n\n`;
let j = 1;
if (deadCycles.length || hitModes.has('navFail')) r += `${j++}. ${FAILURE_KB.navFail.solution}\n`;
for (const k of hitModes) { if (k === 'navFail') continue; r += `${j++}. ${FAILURE_KB[k]?.solution || ''}\n`; }
if (j === 1) r += `保持现状,继续监控。\n`;
return r;
}
function latestTs(): string | null {
if (!fs.existsSync(reportsDir)) return null;
const f = fs.readdirSync(reportsDir).filter((x) => /^soak-\d+-\d+\.log$/.test(x)).sort().pop();
return f ? (f.match(/soak-(\d+-\d+)\.log/) || [])[1] : null;
}
// ---- HTML 报告 ----
function rateColor(rate: number): string {
if (rate >= 0.95) return '#1a9850'; // 绿
if (rate >= 0.85) return '#f5a623'; // 橙
return '#d73027'; // 红
}
function esc(s: string): string {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function renderHtml(d: any): string {
const { ts, startT, endT, cycles, devOrder, stats, healthy, deadCycles } = d;
const hrate = (dv: string) => { const h = healthy[dv] || { pass: 0, total: 0 }; return h.total ? h.pass / h.total : 0; };
// 成功率表
let rateRows = '';
for (const dev of devOrder) {
const s = stats[dev]; const h = healthy[dev] || { pass: 0, total: 0 };
const raw = s.total ? s.pass / s.total : 0; const hh = h.total ? h.pass / h.total : 0;
rateRows += `<tr><td>${esc(dev)}</td><td>${s.pass}/${s.total}</td>` +
`<td><b style="color:${rateColor(raw)}">${(raw * 100).toFixed(1)}%</b></td>` +
`<td><b style="color:${rateColor(hh)}">${(hh * 100).toFixed(1)}%</b> <span class="dim">(${h.pass}/${h.total})</span></td></tr>`;
}
// 失败模式表
let modeRows = '';
for (const dev of devOrder) {
const modes = stats[dev].modes; const keys = Object.keys(modes).sort((a, b) => modes[b] - modes[a]);
if (!keys.length) { modeRows += `<tr><td>${esc(dev)}</td><td class="dim">(无失败明细)</td><td>-</td></tr>`; continue; }
for (const k of keys) modeRows += `<tr><td>${esc(dev)}</td><td>${esc(FAILURE_KB[k]?.label || k)}</td><td>${modes[k]}</td></tr>`;
}
// 结论/问题/解决方案
const stableDevs = devOrder.filter((dv: string) => hrate(dv) >= 0.95);
const weak = devOrder.filter((dv: string) => (healthy[dv]?.total || 0) > 0 && hrate(dv) < 0.85);
const concl: string[] = [];
if (deadCycles.length) concl.push(`本轮存在 <b>${deadCycles.length}</b> 个整轮死区,是整体成功率偏低的<b>主因</b>;按"剔除死区后"看设备真实可靠性更准。`);
if (stableDevs.length) concl.push(`稳定设备(剔除死区后 ≥95%):<b>${stableDevs.map(esc).join('、')}</b>。`);
if (weak.length) concl.push(`偏脆设备(剔除死区后 <85%):<b style="color:#d73027">${weak.map(esc).join('、')}</b> —— 需针对性优化其配对/连接。`);
if (!weak.length && !deadCycles.length) concl.push('各设备成功率均良好,添加流程稳定。');
const hitModes = new Set<string>();
for (const dev of devOrder) Object.keys(stats[dev].modes).forEach((k) => hitModes.add(k));
const probs: string[] = [];
if (deadCycles.length) probs.push(`<b>整轮死区</b>:连续 ${deadCycles.length} 轮全失败,单点卡死引发级联(无人值守缺重启级恢复时尤甚)。`);
for (const k of hitModes) { if (k === 'navFail' && deadCycles.length) continue; probs.push(`<b>${esc(FAILURE_KB[k]?.label || k)}</b>`); }
if (!probs.length) probs.push('无显著问题。');
const sols: string[] = [];
if (deadCycles.length || hitModes.has('navFail')) sols.push(esc(FAILURE_KB.navFail.solution));
for (const k of hitModes) { if (k === 'navFail') continue; if (FAILURE_KB[k]) sols.push(esc(FAILURE_KB[k].solution)); }
if (!sols.length) sols.push('保持现状,继续监控。');
const deadNote = deadCycles.length
? `<p class="warn">检测到 <b>${deadCycles.length}</b> 个"整轮全失败"轮次:Cycle ${deadCycles[0]} ~ ${deadCycles[deadCycles.length - 1]}(全设备同时失败,通常 App 卡死未恢复 → 级联)。</p>`
: `<p>未检测到连续整轮死区。</p>`;
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<title>Soak ${ts}</title><style>
body{font-family:-apple-system,"PingFang SC",Segoe UI,sans-serif;margin:32px;color:#222;background:#fafafa;line-height:1.6}
h1{font-size:22px}h2{font-size:17px;border-left:4px solid #4a90d9;padding-left:10px;margin-top:28px}
table{border-collapse:collapse;margin:10px 0;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08)}
th,td{border:1px solid #e2e2e2;padding:7px 14px;text-align:left;font-size:14px}
th{background:#f0f4f8}.dim{color:#999;font-size:12px}
.meta{color:#555;font-size:13px}.warn{background:#fff3cd;border-left:4px solid #f5a623;padding:8px 12px}
ol{padding-left:22px}ol li{margin:5px 0}
</style></head><body>
<h1> Soak </h1>
<p class="meta">运行:${esc(startT)} ${esc(endT)} | 轮次:${cycles} | 日志:soak-${ts}</p>
<h2></h2>
<table><tr><th></th><th>/</th><th></th><th></th></tr>${rateRows}</table>
<p class="dim">"剔除死区后"= ,</p>
<h2></h2>
<table><tr><th></th><th></th><th></th></tr>${modeRows}</table>
<h2>线()</h2>${deadNote}
<h2></h2><ol>${concl.map((c) => `<li>${c}</li>`).join('')}</ol>
<h2></h2><ol>${probs.map((c) => `<li>${c}</li>`).join('')}</ol>
<h2></h2><ol>${sols.map((c) => `<li>${c}</li>`).join('')}</ol>
</body></html>`;
}
// 兼容:healthy 以设备名为 key
main();

View File

@ -1,219 +0,0 @@
// AUTO-GENERATED by scripts/gen-must-test-manifest.ts — do not edit by hand.
// 源: ONES plan CQz9YCNX / lib EPfZfC9Y (必测项-AI自动化)。重新生成: npm run gen:must-test
// 详见 prompts/must_test_conversion.md
export type MustTestItem =
| {
kind: 'add' | 'feature';
ones: number;
name: string;
cat: string;
device: string;
file: string;
testName: string;
status: 'todo' | 'na' | 'done';
}
| {
kind: 'ctrl';
ones: number;
step: string;
proto: 'ble' | 'wifi';
name: string;
result: string;
cat: string;
device: string;
action: string;
file: string;
testName: string;
status: 'todo' | 'na' | 'done';
};
export const MUST_TEST: MustTestItem[] = [
{"kind":"add","ones":393236,"name":"添加Candle lamp融蜡灯验证融蜡灯需要插110V电压","cat":"ceiling_light","device":"CeilingLight 7Q","file":"tests/ceiling_light/ceiling_light_connect.test.ts","testName":"[P0][ONES:393236] 添加Candle lamp融蜡灯验证融蜡灯需要插110V电压","status":"todo"},
{"kind":"add","ones":393235,"name":"添加RGBIC Neon Rope Light炫彩霓虹灯验证","cat":"strip_light","device":"StripLight 9S","file":"tests/strip_light/strip_light_connect.test.ts","testName":"[P0][ONES:393235] 添加RGBIC Neon Rope Light炫彩霓虹灯验证","status":"todo"},
{"kind":"add","ones":393234,"name":"添加Standing Circulator fan验证","cat":"fan","device":"Fan 0T","file":"tests/fan/fan_connect.test.ts","testName":"[P0][ONES:393234] 添加Standing Circulator fan验证","status":"todo"},
{"kind":"add","ones":332919,"name":"添加safety alarm","cat":"safety_alarm","device":"","file":"","testName":"","status":"na"},
{"kind":"add","ones":332918,"name":"添加eu plug","cat":"plug","device":"Plug 4D","file":"tests/plug/plug_connect.test.ts","testName":"[P0][ONES:332918] 添加eu plug","status":"todo"},
{"kind":"add","ones":332917,"name":"添加人体存在传感器","cat":"sensor","device":"Contact Sensor 5O","file":"tests/sensor/sensor_connect.test.ts","testName":"[P0][ONES:332917] 添加人体存在传感器","status":"todo"},
{"kind":"add","ones":319305,"name":"添加Hub Mini S3验证","cat":"hub","device":"Hub2 8H","file":"tests/hub/hub_connect.test.ts","testName":"[P0][ONES:319305] 添加Hub Mini S3验证","status":"todo"},
{"kind":"add","ones":319304,"name":"添加Hub2 S3验证","cat":"hub","device":"Hub2 8H","file":"tests/hub/hub_connect.test.ts","testName":"[P0][ONES:319304] 添加Hub2 S3验证","status":"todo"},
{"kind":"add","ones":319303,"name":"添加Hub Mini Matter S3验证","cat":"hub","device":"Hub2 8H","file":"tests/hub/hub_connect.test.ts","testName":"[P0][ONES:319303] 添加Hub Mini Matter S3验证","status":"todo"},
{"kind":"add","ones":311020,"name":"添加RGBIC Neon Wire Rope Light炫彩钢丝霓虹灯验证","cat":"strip_light","device":"StripLight 9S","file":"tests/strip_light/strip_light_connect.test.ts","testName":"[P0][ONES:311020] 添加RGBIC Neon Wire Rope Light炫彩钢丝霓虹灯验证","status":"todo"},
{"kind":"add","ones":311019,"name":"添加RGBICWW Floor Lamp炫彩落地灯验证","cat":"ceiling_light","device":"CeilingLight 7Q","file":"tests/ceiling_light/ceiling_light_connect.test.ts","testName":"[P0][ONES:311019] 添加RGBICWW Floor Lamp炫彩落地灯验证","status":"todo"},
{"kind":"add","ones":311018,"name":"添加RGBICWW Strip Light炫彩灯带 验证","cat":"strip_light","device":"StripLight 9S","file":"tests/strip_light/strip_light_connect.test.ts","testName":"[P0][ONES:311018] 添加RGBICWW Strip Light炫彩灯带 验证","status":"todo"},
{"kind":"add","ones":310704,"name":"添加暖气阀Smart Radiator Thermostat验证","cat":"meter","device":"Meter 3M","file":"tests/meter/meter_connect.test.ts","testName":"[P0][ONES:310704] 添加暖气阀Smart Radiator Thermostat验证","status":"todo"},
{"kind":"add","ones":310703,"name":"添加Climate Panel验证","cat":"meter","device":"Meter 3M","file":"tests/meter/meter_connect.test.ts","testName":"[P0][ONES:310703] 添加Climate Panel验证","status":"todo"},
{"kind":"add","ones":304133,"name":"添加Lock Pro WiFi验证","cat":"lock","device":"Lock 6F","file":"tests/lock/lock_connect.test.ts","testName":"[P0][ONES:304133] 添加Lock Pro WiFi验证","status":"todo"},
{"kind":"add","ones":304132,"name":"添加Lock Lite验证","cat":"lock","device":"Lock 6F","file":"tests/lock/lock_connect.test.ts","testName":"[P0][ONES:304132] 添加Lock Lite验证","status":"todo"},
{"kind":"add","ones":304131,"name":"添加Keypad Vision验证","cat":"keypad","device":"Keypad 2V","file":"tests/keypad/keypad_connect.test.ts","testName":"[P0][ONES:304131] 添加Keypad Vision验证","status":"todo"},
{"kind":"add","ones":304130,"name":"添加Lock Ultra验证","cat":"lock","device":"Lock 6F","file":"tests/lock/lock_connect.test.ts","testName":"[P0][ONES:304130] 添加Lock Ultra验证","status":"todo"},
{"kind":"add","ones":302289,"name":"添加Garage Door Opener验证","cat":"plug","device":"Plug 4D","file":"tests/plug/plug_connect.test.ts","testName":"[P0][ONES:302289] 添加Garage Door Opener验证","status":"todo"},
{"kind":"add","ones":302288,"name":"添加Relay Switch验证","cat":"plug","device":"Plug 4D","file":"tests/plug/plug_connect.test.ts","testName":"[P0][ONES:302288] 添加Relay Switch验证","status":"todo"},
{"kind":"add","ones":301586,"name":"添加RGBWW Floor Lamp (落地灯)验证","cat":"ceiling_light","device":"CeilingLight 7Q","file":"tests/ceiling_light/ceiling_light_connect.test.ts","testName":"[P0][ONES:301586] 添加RGBWW Floor Lamp (落地灯)验证","status":"todo"},
{"kind":"add","ones":301585,"name":"添加RGBWW Strip light3(彩色灯带3 验证 ","cat":"strip_light","device":"StripLight 9S","file":"tests/strip_light/strip_light_connect.test.ts","testName":"[P0][ONES:301585] 添加RGBWW Strip light3(彩色灯带3 验证 ","status":"todo"},
{"kind":"add","ones":273787,"name":"添加K11+扫地机","cat":"robot","device":"Robot S1","file":"tests/robot/robot_connect.test.ts","testName":"[P0][ONES:273787] 添加K11+扫地机","status":"todo"},
{"kind":"add","ones":267367,"name":"添加Meter pro验证","cat":"meter","device":"Meter 3M","file":"tests/meter/meter_connect.test.ts","testName":"[P0][ONES:267367] 添加Meter pro验证","status":"todo"},
{"kind":"add","ones":267366,"name":"添加Meter pro co2验证","cat":"meter","device":"Meter 3M","file":"tests/meter/meter_connect.test.ts","testName":"[P0][ONES:267366] 添加Meter pro co2验证","status":"todo"},
{"kind":"add","ones":265933,"name":"添加curtain 3 2025验证","cat":"curtain","device":"Curtain 1A","file":"tests/curtain/curtain_connect.test.ts","testName":"[P0][ONES:265933] 添加curtain 3 2025验证","status":"todo"},
{"kind":"add","ones":205248,"name":"添加S20扫地机","cat":"robot","device":"Robot S1","file":"tests/robot/robot_connect.test.ts","testName":"[P0][ONES:205248] 添加S20扫地机","status":"todo"},
{"kind":"add","ones":197389,"name":"添加Doorbell","cat":"camera","device":"摄像机Plus 3K","file":"tests/camera/camera_connect.test.ts","testName":"[P0][ONES:197389] 添加Doorbell","status":"todo"},
{"kind":"add","ones":196802,"name":"添加空气净化器验证基础款和table款","cat":"air_condition","device":"AirCondition IR","file":"tests/air_condition/air_condition_connect.test.ts","testName":"[P0][ONES:196802] 添加空气净化器验证基础款和table款","status":"todo"},
{"kind":"add","ones":191100,"name":"添加Roller Shade验证","cat":"curtain","device":"Curtain 1A","file":"tests/curtain/curtain_connect.test.ts","testName":"[P0][ONES:191100] 添加Roller Shade验证","status":"todo"},
{"kind":"feature","ones":190802,"name":"同步URC设备、控制","cat":"urc","device":"URC 5Y","file":"tests/urc/urc_control.test.ts","testName":"[P0][ONES:190802] 同步URC设备、控制","status":"todo"},
{"kind":"add","ones":190801,"name":"添加URC验证","cat":"urc","device":"URC 5Y","file":"tests/urc/urc_connect.test.ts","testName":"[P0][ONES:190801] 添加URC验证","status":"todo"},
{"kind":"feature","ones":162264,"name":"K10+ Pro Combo解除绑定手持","cat":"robot","device":"Robot S1","file":"tests/robot/robot_control.test.ts","testName":"[P0][ONES:162264] K10+ Pro Combo解除绑定手持","status":"todo"},
{"kind":"feature","ones":162263,"name":"K10+ Pro Combo绑定手持吸尘器","cat":"robot","device":"Robot S1","file":"tests/robot/robot_control.test.ts","testName":"[P0][ONES:162263] K10+ Pro Combo绑定手持吸尘器","status":"todo"},
{"kind":"add","ones":162261,"name":"添加K10+ Pro Combo","cat":"robot","device":"Robot S1","file":"tests/robot/robot_connect.test.ts","testName":"[P0][ONES:162261] 添加K10+ Pro Combo","status":"todo"},
{"kind":"add","ones":122775,"name":"添加 Circulator Fan验证无电池款","cat":"fan","device":"Fan 0T","file":"tests/fan/fan_connect.test.ts","testName":"[P0][ONES:122775] 添加 Circulator Fan验证无电池款","status":"todo"},
{"kind":"add","ones":122737,"name":"添加K10+Pro扫地机","cat":"robot","device":"Robot S1","file":"tests/robot/robot_connect.test.ts","testName":"[P0][ONES:122737] 添加K10+Pro扫地机","status":"todo"},
{"kind":"add","ones":113943,"name":"添加PTC Plus 5MP","cat":"camera","device":"摄像机Plus 3K","file":"tests/camera/camera_connect.test.ts","testName":"[P0][ONES:113943] 添加PTC Plus 5MP","status":"todo"},
{"kind":"add","ones":113942,"name":"添加OSC 2K验证","cat":"osc","device":"OSC 2K","file":"tests/osc/osc_connect.test.ts","testName":"[P0][ONES:113942] 添加OSC 2K验证","status":"todo"},
{"kind":"add","ones":113941,"name":"添加OSC验证","cat":"osc","device":"OSC 2K","file":"tests/osc/osc_connect.test.ts","testName":"[P0][ONES:113941] 添加OSC验证","status":"todo"},
{"kind":"add","ones":105436,"name":"添加lock Pro&kit验证","cat":"lock","device":"Lock 6F","file":"tests/lock/lock_connect.test.ts","testName":"[P0][ONES:105436] 添加lock Pro&kit验证","status":"todo"},
{"kind":"add","ones":78369,"name":"添加S10扫地机","cat":"robot","device":"Robot S1","file":"tests/robot/robot_connect.test.ts","testName":"[P0][ONES:78369] 添加S10扫地机","status":"todo"},
{"kind":"feature","ones":74105,"name":"强绑定设备的自动解绑申请","cat":"","device":"","file":"","testName":"","status":"na"},
{"kind":"add","ones":74104,"name":"添加Humidifier2验证","cat":"humidifier","device":"Humidifier 1K","file":"tests/humidifier/humidifier_connect.test.ts","testName":"[P0][ONES:74104] 添加Humidifier2验证","status":"todo"},
{"kind":"feature","ones":74078,"name":"APP切换温湿度单位显示","cat":"meter","device":"Meter 3M","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:74078] APP切换温湿度单位显示","status":"todo"},
{"kind":"add","ones":66694,"name":"添加Lock Pro验证","cat":"lock","device":"Lock 6F","file":"tests/lock/lock_connect.test.ts","testName":"[P0][ONES:66694] 添加Lock Pro验证","status":"todo"},
{"kind":"add","ones":65336,"name":"添加Hub Mini Matter验证","cat":"hub","device":"Hub2 8H","file":"tests/hub/hub_connect.test.ts","testName":"[P0][ONES:65336] 添加Hub Mini Matter验证","status":"todo"},
{"kind":"add","ones":51440,"name":"添加lock&kit验证","cat":"lock","device":"Lock 6F","file":"tests/lock/lock_connect.test.ts","testName":"[P0][ONES:51440] 添加lock&kit验证","status":"todo"},
{"kind":"add","ones":48159,"name":"添加Battery Circulator Fan验证","cat":"fan","device":"Fan 0T","file":"tests/fan/fan_connect.test.ts","testName":"[P0][ONES:48159] 添加Battery Circulator Fan验证","status":"todo"},
{"kind":"add","ones":47214,"name":"添加PTC Plus 3MP","cat":"camera","device":"摄像机Plus 3K","file":"tests/camera/camera_connect.test.ts","testName":"[P0][ONES:47214] 添加PTC Plus 3MP","status":"todo"},
{"kind":"add","ones":40955,"name":"添加curtain 3验证","cat":"curtain","device":"Curtain 1A","file":"tests/curtain/curtain_connect.test.ts","testName":"[P0][ONES:40955] 添加curtain 3验证","status":"todo"},
{"kind":"add","ones":40954,"name":"添加K10+扫地机","cat":"robot","device":"Robot S1","file":"tests/robot/robot_connect.test.ts","testName":"[P0][ONES:40954] 添加K10+扫地机","status":"todo"},
{"kind":"add","ones":35871,"name":"添加IOSensor验证","cat":"sensor","device":"Contact Sensor 5O","file":"tests/sensor/sensor_connect.test.ts","testName":"[P0][ONES:35871] 添加IOSensor验证","status":"todo"},
{"kind":"add","ones":21787,"name":"添加Hub2 验证","cat":"hub","device":"Hub2 8H","file":"tests/hub/hub_connect.test.ts","testName":"[P0][ONES:21787] 添加Hub2 验证","status":"todo"},
{"kind":"feature","ones":15973,"name":"keypad、keypad touch添加密码、指纹、卡片验证","cat":"keypad","device":"Keypad 2V","file":"tests/keypad/keypad_control.test.ts","testName":"[P0][ONES:15973] keypad、keypad touch添加密码、指纹、卡片验证","status":"todo"},
{"kind":"add","ones":15972,"name":"添加Hub Mini验证","cat":"hub","device":"Hub2 8H","file":"tests/hub/hub_connect.test.ts","testName":"[P0][ONES:15972] 添加Hub Mini验证","status":"todo"},
{"kind":"add","ones":15971,"name":"添加Hub Plus验证","cat":"hub","device":"Hub2 8H","file":"tests/hub/hub_connect.test.ts","testName":"[P0][ONES:15971] 添加Hub Plus验证","status":"todo"},
{"kind":"add","ones":15970,"name":"添加Lock验证","cat":"lock","device":"Lock 6F","file":"tests/lock/lock_connect.test.ts","testName":"[P0][ONES:15970] 添加Lock验证","status":"todo"},
{"kind":"add","ones":15969,"name":"添加Curtain验证","cat":"curtain","device":"Curtain 1A","file":"tests/curtain/curtain_connect.test.ts","testName":"[P0][ONES:15969] 添加Curtain验证","status":"todo"},
{"kind":"add","ones":15968,"name":"添加Bot验证","cat":"bot","device":"Bot 0F","file":"tests/bot/bot_connect.test.ts","testName":"[P0][ONES:15968] 添加Bot验证","status":"todo"},
{"kind":"add","ones":15967,"name":"添加Remote验证","cat":"remote","device":"Remote 3W","file":"tests/remote/remote_connect.test.ts","testName":"[P0][ONES:15967] 添加Remote验证","status":"todo"},
{"kind":"add","ones":15966,"name":"添加Plug验证","cat":"plug","device":"Plug 4D","file":"tests/plug/plug_connect.test.ts","testName":"[P0][ONES:15966] 添加Plug验证","status":"todo"},
{"kind":"add","ones":15965,"name":"添加Meter验证","cat":"meter","device":"Meter 3M","file":"tests/meter/meter_connect.test.ts","testName":"[P0][ONES:15965] 添加Meter验证","status":"todo"},
{"kind":"add","ones":15964,"name":"添加Humidifier验证","cat":"humidifier","device":"Humidifier 1K","file":"tests/humidifier/humidifier_connect.test.ts","testName":"[P0][ONES:15964] 添加Humidifier验证","status":"todo"},
{"kind":"add","ones":15963,"name":"添加Motion Sensor验证","cat":"sensor","device":"Contact Sensor 5O","file":"tests/sensor/sensor_connect.test.ts","testName":"[P0][ONES:15963] 添加Motion Sensor验证","status":"todo"},
{"kind":"add","ones":15962,"name":"添加Contact Sensor验证","cat":"sensor","device":"Contact Sensor 5O","file":"tests/sensor/sensor_connect.test.ts","testName":"[P0][ONES:15962] 添加Contact Sensor验证","status":"todo"},
{"kind":"add","ones":15961,"name":"添加Indoor Cam验证","cat":"camera","device":"摄像机Plus 3K","file":"tests/camera/camera_connect.test.ts","testName":"[P0][ONES:15961] 添加Indoor Cam验证","status":"todo"},
{"kind":"add","ones":15960,"name":"添加Color Bulb验证","cat":"color_bulb","device":"ColorBulb 8R","file":"tests/color_bulb/color_bulb_connect.test.ts","testName":"[P0][ONES:15960] 添加Color Bulb验证","status":"todo"},
{"kind":"add","ones":15959,"name":"添加Strip Light验证","cat":"strip_light","device":"StripLight 9S","file":"tests/strip_light/strip_light_connect.test.ts","testName":"[P0][ONES:15959] 添加Strip Light验证","status":"todo"},
{"kind":"add","ones":15958,"name":"添加Plug Mini验证JP和US","cat":"plug","device":"Plug 4D","file":"tests/plug/plug_connect.test.ts","testName":"[P0][ONES:15958] 添加Plug Mini验证JP和US","status":"todo"},
{"kind":"add","ones":15957,"name":"添加Pan/Tilt Cam验证","cat":"camera","device":"摄像机Plus 3K","file":"tests/camera/camera_connect.test.ts","testName":"[P0][ONES:15957] 添加Pan/Tilt Cam验证","status":"todo"},
{"kind":"add","ones":15956,"name":"添加Meter Plus验证","cat":"meter","device":"Meter 3M","file":"tests/meter/meter_connect.test.ts","testName":"[P0][ONES:15956] 添加Meter Plus验证","status":"todo"},
{"kind":"feature","ones":15955,"name":"智能匹配遥控器","cat":"remote","device":"Remote 3W","file":"tests/remote/remote_control.test.ts","testName":"[P0][ONES:15955] 智能匹配遥控器","status":"todo"},
{"kind":"feature","ones":15954,"name":"学习自定义遥控器","cat":"remote","device":"Remote 3W","file":"tests/remote/remote_control.test.ts","testName":"[P0][ONES:15954] 学习自定义遥控器","status":"todo"},
{"kind":"feature","ones":15953,"name":"学习others遥控器","cat":"remote","device":"Remote 3W","file":"tests/remote/remote_control.test.ts","testName":"[P0][ONES:15953] 学习others遥控器","status":"todo"},
{"kind":"add","ones":15952,"name":"添加Robot Vacuum S1验证","cat":"robot","device":"Robot S1","file":"tests/robot/robot_connect.test.ts","testName":"[P0][ONES:15952] 添加Robot Vacuum S1验证","status":"todo"},
{"kind":"add","ones":15951,"name":"添加Robot Vacuum S1 Plus验证","cat":"robot","device":"Robot S1","file":"tests/robot/robot_connect.test.ts","testName":"[P0][ONES:15951] 添加Robot Vacuum S1 Plus验证","status":"todo"},
{"kind":"add","ones":15950,"name":"添加吸顶灯验证Pro和lte","cat":"ceiling_light","device":"CeilingLight 7Q","file":"tests/ceiling_light/ceiling_light_connect.test.ts","testName":"[P0][ONES:15950] 添加吸顶灯验证Pro和lte","status":"todo"},
{"kind":"add","ones":15949,"name":"添加keypad验证","cat":"keypad","device":"Keypad 2V","file":"tests/keypad/keypad_connect.test.ts","testName":"[P0][ONES:15949] 添加keypad验证","status":"todo"},
{"kind":"add","ones":15948,"name":"添加keypad touch验证","cat":"keypad","device":"Keypad 2V","file":"tests/keypad/keypad_connect.test.ts","testName":"[P0][ONES:15948] 添加keypad touch验证","status":"todo"},
{"kind":"add","ones":15947,"name":"添加Pan/Tilt Cam 2K验证","cat":"camera","device":"摄像机Plus 3K","file":"tests/camera/camera_connect.test.ts","testName":"[P0][ONES:15947] 添加Pan/Tilt Cam 2K验证","status":"todo"},
{"kind":"add","ones":15946,"name":"添加Blind Tilt验证","cat":"curtain","device":"Curtain 1A","file":"tests/curtain/curtain_connect.test.ts","testName":"[P0][ONES:15946] 添加Blind Tilt验证","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"6e4uZSVe","proto":"ble","name":"点击控制Bot 不加密开&不加密关&加密按压","result":"对应Bot固件响应动作","cat":"bot","device":"Bot 0F","action":"点击控制Bot 不加密开&不加密关&加密按压","file":"tests/bot/bot_control.test.ts","testName":"[P0][ONES:15975#6e4uZSVe][ble] 点击控制Bot 不加密开&不加密关&加密按压 Bot 0F","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"5YEfmpJz","proto":"ble","name":"点击控制Lock 开锁/解锁","result":"对应Lock固件响应动作开锁/解锁","cat":"lock","device":"Lock 6F","action":"点击控制Lock 开锁/解锁","file":"tests/lock/lock_control.test.ts","testName":"[P0][ONES:15975#5YEfmpJz][ble] 点击控制Lock 开锁/解锁 Lock 6F","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"774V8bND","proto":"ble","name":"Curtain---快捷弹窗---开/关","result":"对应Curtain固件响应动作开/关","cat":"curtain","device":"Curtain 1A","action":"Curtain---快捷弹窗---开/关","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15975#774V8bND][ble] Curtain---快捷弹窗---开/关 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"NvpUv9Tt","proto":"ble","name":"Meter---历史数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"历史数据能够加载不会有特别异常的数据图表主页对应Meter温湿度字体变红色","cat":"meter","device":"Meter 3M","action":"Meter---历史数据---右上角设置---报","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15975#NvpUv9Tt][ble] Meter---历史数据---右上角设置---报 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"3Jm5FAdW","proto":"ble","name":"Meter---历史数据---右上角设置---校正---校准温度---下一步,调整温度范围增加温度","result":"温度校正过程中对应Meter固件的温度值也相应变化","cat":"meter","device":"Meter 3M","action":"Meter---历史数据---右上角设置---校","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15975#3Jm5FAdW][ble] Meter---历史数据---右上角设置---校 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"QfYLdj8s","proto":"ble","name":"执行步骤5至校正---校正湿度---下一步,调整湿度范围增加湿度","result":"湿度校正过程中对应Meter固件的湿度值也相应变化","cat":"humidifier","device":"Humidifier 1K","action":"执行步骤5至校正---校正湿度---下一步,调整","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15975#QfYLdj8s][ble] 执行步骤5至校正---校正湿度---下一步,调整 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"GkkrZpaC","proto":"ble","name":"Humidifier---快捷弹窗---开启加湿器自动/低/中/高档/关闭","result":"对应Humidifier固件响应动作开启加湿器自动/低/中/高档/关闭","cat":"humidifier","device":"Humidifier 1K","action":"Humidifier---快捷弹窗---开启加湿","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15975#GkkrZpaC][ble] Humidifier---快捷弹窗---开启加湿 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"TNhSAuUC","proto":"ble","name":"点击控制Bulb 开/关","result":"对应Bulb固件响应动作开/关","cat":"color_bulb","device":"ColorBulb 8R","action":"点击控制Bulb 开/关","file":"tests/color_bulb/color_bulb_control.test.ts","testName":"[P0][ONES:15975#TNhSAuUC][ble] 点击控制Bulb 开/关 ColorBulb 8R","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"4poyiVwD","proto":"ble","name":"点击控制Strip Light开/关","result":"对应Strip Light固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制Strip Light开/关","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15975#4poyiVwD][ble] 点击控制Strip Light开/关 StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"2528azD6","proto":"ble","name":"点击控制吸顶灯pro开/关灯","result":"对应吸顶灯pro固件响应动作开/关灯","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制吸顶灯pro开/关灯","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15975#2528azD6][ble] 点击控制吸顶灯pro开/关灯 CeilingLight 7Q","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"V15M64cP","proto":"ble","name":"点击控制吸顶灯lte开/关灯","result":"对应吸顶灯lte固件响应动作开/关灯","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制吸顶灯lte开/关灯","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15975#V15M64cP][ble] 点击控制吸顶灯lte开/关灯 CeilingLight 7Q","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"2jSFfY4k","proto":"ble","name":"点击控制Plug Mini开/关","result":"对应Plug Mini固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Plug Mini开/关","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15975#2jSFfY4k][ble] 点击控制Plug Mini开/关 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"UZCtZRPy","proto":"ble","name":"Meter Plus---历史数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"历史数据能够加载不会有特别异常的数据图表主页对应Meter Plus温湿度字体变红色","cat":"meter","device":"Meter 3M","action":"Meter Plus---历史数据---右上角设","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15975#UZCtZRPy][ble] Meter Plus---历史数据---右上角设 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"9mheLMDJ","proto":"ble","name":"Meter Plus---历史数据---右上角设置---校正---校准温度---下一步,调整温度范围增加温度","result":"温度校正过程中对应Meter Plus固件的温度值也相应变化","cat":"meter","device":"Meter 3M","action":"Meter Plus---历史数据---右上角设","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15975#9mheLMDJ][ble] Meter Plus---历史数据---右上角设 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"GSZv8BYm","proto":"ble","name":"执行步骤14至校正---校正湿度---下一步,调整湿度范围增加湿度","result":"湿度校正过程中对应Meter Plus固件的湿度值也相应变化","cat":"humidifier","device":"Humidifier 1K","action":"执行步骤14至校正---校正湿度---下一步,调","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15975#GSZv8BYm][ble] 执行步骤14至校正---校正湿度---下一步,调 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"HrigrD2X","proto":"ble","name":"Motion Sensor---功能页面在Motion Sensor固件前移动","result":"功能页面正常显示有人移动","cat":"sensor","device":"Contact Sensor 5O","action":"Motion Sensor---功能页面在Mo","file":"tests/sensor/sensor_control.test.ts","testName":"[P0][ONES:15975#HrigrD2X][ble] Motion Sensor---功能页面在Mo Contact Sensor 5O","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"A8C4BsCm","proto":"ble","name":"Contact Sensor---功能页面开关Contact Sensor固件","result":"功能页面正常显示Contact Sensor的开关状态","cat":"sensor","device":"Contact Sensor 5O","action":"Contact Sensor---功能页面,开关","file":"tests/sensor/sensor_control.test.ts","testName":"[P0][ONES:15975#A8C4BsCm][ble] Contact Sensor---功能页面,开关 Contact Sensor 5O","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"3frMRDZj","proto":"ble","name":"点击控制固件V20版本BlindTilt首页按钮","result":"响应到正确开合状态","cat":"curtain","device":"Curtain 1A","action":"点击控制固件V20版本BlindTilt首页按钮","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15975#3frMRDZj][ble] 点击控制固件V20版本BlindTilt首页按钮 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"Qbm9VB72","proto":"ble","name":"点击控制固件V12版本BlindTil功能页按钮验证兼容旧版本","result":"响应到正确开合状态","cat":"curtain","device":"Curtain 1A","action":"点击控制固件V12版本BlindTil功能页按钮","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15975#Qbm9VB72][ble] 点击控制固件V12版本BlindTil功能页按钮 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"QwnZJARA","proto":"ble","name":"IOSensor---历史数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"历史数据能够加载不会有特别异常的数据图表主页对应IOSensor温湿度字体变红色","cat":"sensor","device":"Contact Sensor 5O","action":"IOSensor---历史数据---右上角设置-","file":"tests/sensor/sensor_control.test.ts","testName":"[P0][ONES:15975#QwnZJARA][ble] IOSensor---历史数据---右上角设置- Contact Sensor 5O","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"Ncg7BDeR","proto":"ble","name":"IOSensor---历史数据---右上角设置---校正---校准温度---下一步,调整温度范围增加温度","result":"温度校正过程中对应IOSensor固件的温度值也相应变化","cat":"sensor","device":"Contact Sensor 5O","action":"IOSensor---历史数据---右上角设置-","file":"tests/sensor/sensor_control.test.ts","testName":"[P0][ONES:15975#Ncg7BDeR][ble] IOSensor---历史数据---右上角设置- Contact Sensor 5O","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"k6cqKqtZ","proto":"ble","name":"执行步骤21至校正---校正湿度---下一步,调整湿度范围增加湿度","result":"湿度校正过程中对应IOSensor固件的湿度值也相应变化","cat":"humidifier","device":"Humidifier 1K","action":"执行步骤21至校正---校正湿度---下一步,调","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15975#k6cqKqtZ][ble] 执行步骤21至校正---校正湿度---下一步,调 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"QqsnPMc3","proto":"ble","name":"Curtain 3---快捷弹窗---开/关","result":"对应Curtain 3固件响应动作开/关","cat":"curtain","device":"Curtain 1A","action":"Curtain 3---快捷弹窗---开/关","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15975#QqsnPMc3][ble] Curtain 3---快捷弹窗---开/关 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"CiFPEbNS","proto":"ble","name":"带电池款Battery Circulator Fan---首页控制开关","result":"对应循环扇开关","cat":"fan","device":"Fan 0T","action":"带电池款Battery Circulator F","file":"tests/fan/fan_control.test.ts","testName":"[P0][ONES:15975#CiFPEbNS][ble] 带电池款Battery Circulator F Fan 0T","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"LnPNEJmS","proto":"ble","name":"点击控制Lock Pro开锁/解锁","result":"对应Lock Pro固件响应动作开锁/解锁","cat":"lock","device":"Lock 6F","action":"点击控制Lock Pro开锁/解锁","file":"tests/lock/lock_control.test.ts","testName":"[P0][ONES:15975#LnPNEJmS][ble] 点击控制Lock Pro开锁/解锁 Lock 6F","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"3PmZMk1s","proto":"ble","name":"进入Humidifier2设置页绑定温湿度计点击切换档位、模式","result":"绑定成功,功能页显示对应湿度值;切换档位、模式、目标湿度正常。","cat":"humidifier","device":"Humidifier 1K","action":"进入Humidifier2设置页绑定温湿度计","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15975#3PmZMk1s][ble] 进入Humidifier2设置页绑定温湿度计 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"JBszgxRS","proto":"ble","name":"无电池款Circulator Fan---首页控制开关","result":"对应循环扇开关","cat":"fan","device":"Fan 0T","action":"无电池款Circulator Fan---首页控","file":"tests/fan/fan_control.test.ts","testName":"[P0][ONES:15975#JBszgxRS][ble] 无电池款Circulator Fan---首页控 Fan 0T","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"SmzuHtWi","proto":"ble","name":"Roller Shade功能页控制设备全开、暂停、全关、任意百分比控制","result":"Roller Shade正常响应所有控制指令","cat":"curtain","device":"Curtain 1A","action":"Roller Shade功能页控制设备全开、暂停","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15975#SmzuHtWi][ble] Roller Shade功能页控制设备全开、暂停 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"7qxA1ajE","proto":"ble","name":"点击控制美规基础款空气净化器--开/关","result":"对应美规基础款空气净化器开关","cat":"air_condition","device":"AirCondition IR","action":"点击控制美规基础款空气净化器--开/关","file":"tests/air_condition/air_condition_control.test.ts","testName":"[P0][ONES:15975#7qxA1ajE][ble] 点击控制美规基础款空气净化器--开/关 AirCondition IR","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"R5PDp8BR","proto":"ble","name":"点击控制日规Table空气净化器--开/关","result":"对应日规Table空气净化器开/关","cat":"air_condition","device":"AirCondition IR","action":"点击控制日规Table空气净化器--开/关","file":"tests/air_condition/air_condition_control.test.ts","testName":"[P0][ONES:15975#R5PDp8BR][ble] 点击控制日规Table空气净化器--开/关 AirCondition IR","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"3wM6fW8W","proto":"ble","name":"Curtain 3 2025---快捷弹窗---开/关","result":"对应Curtain 3 2025固件响应动作开/关","cat":"curtain","device":"Curtain 1A","action":"Curtain 3 2025---快捷弹窗---","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15975#3wM6fW8W][ble] Curtain 3 2025---快捷弹窗--- Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"JggzNXDo","proto":"ble","name":"Meter pro---图表数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"图表数据能够加载不会有特别异常的数据图表主页对应Meter Pro温湿度字体变红色","cat":"meter","device":"Meter 3M","action":"Meter pro---图表数据---右上角设置","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15975#JggzNXDo][ble] Meter pro---图表数据---右上角设置 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"L967RGHa","proto":"ble","name":"Meter pro---设置页--校正---校准温度---下一步,调整温度范围增加温度","result":"温度校正过程中对应Meter Pro固件的温度值也相应变化","cat":"meter","device":"Meter 3M","action":"Meter pro---设置页--校正---校准","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15975#L967RGHa][ble] Meter pro---设置页--校正---校准 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"8Wzaj5pB","proto":"ble","name":"校正---校正湿度---下一步,调整湿度范围增加湿度","result":"湿度校正过程中对应Meter Pro固件的湿度值也相应变化","cat":"humidifier","device":"Humidifier 1K","action":"校正---校正湿度---下一步,调整湿度范围增加","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15975#8Wzaj5pB][ble] 校正---校正湿度---下一步,调整湿度范围增加 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"3bYsmiZG","proto":"ble","name":"Meter pro co2---图表数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"图表数据能够加载不会有特别异常的数据图表主页对应Meter Pro co2温湿度字体变红色","cat":"meter","device":"Meter 3M","action":"Meter pro co2---图表数据---右","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15975#3bYsmiZG][ble] Meter pro co2---图表数据---右 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"NDqRQxVx","proto":"ble","name":"Meter pro co2---设置页--校正---校准温度---下一步,调整温度范围增加温度","result":"温度校正过程中对应Meter Pro co2固件的温度值也相应变化","cat":"meter","device":"Meter 3M","action":"Meter pro co2---设置页--校正-","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15975#NDqRQxVx][ble] Meter pro co2---设置页--校正- Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"DxKG9Acs","proto":"ble","name":"校正---校正湿度---下一步,调整湿度范围增加湿度","result":"湿度校正过程中对应Meter Pro co2固件的湿度值也相应变化","cat":"humidifier","device":"Humidifier 1K","action":"校正---校正湿度---下一步,调整湿度范围增加","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15975#DxKG9Acs][ble] 校正---校正湿度---下一步,调整湿度范围增加 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"3ob75cjf","proto":"ble","name":"点击控制RGBWW 彩色灯带3 --开/关","result":"对应RGBWW 彩色灯带3 --固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制RGBWW 彩色灯带3 --开/关","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15975#3ob75cjf][ble] 点击控制RGBWW 彩色灯带3 --开/关 StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"JVnRz7xY","proto":"ble","name":"点击控制RGBWW 落地灯 --开/关","result":"对应RGBWW 落地灯 --固件响应动作开/关","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制RGBWW 落地灯 --开/关","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15975#JVnRz7xY][ble] 点击控制RGBWW 落地灯 --开/关 CeilingLight 7Q","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"MKgLq1oy","proto":"ble","name":"点击控制Relay switch 1pm","result":"对应Relay Switch 1pm -固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Relay switch 1pm","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15975#MKgLq1oy][ble] 点击控制Relay switch 1pm Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"A1TLxsFw","proto":"ble","name":"点击控制Relay switch 1","result":"对应Relay Switch 1-固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Relay switch 1","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15975#A1TLxsFw][ble] 点击控制Relay switch 1 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"PG62s725","proto":"ble","name":"点击控制Relay switch 2pm-开关控制器","result":"对应Relay Switch 2pm开关控制器-固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Relay switch 2pm-开关控","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15975#PG62s725][ble] 点击控制Relay switch 2pm-开关控 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"AafJ5tj5","proto":"ble","name":"点击控制Relay switch 2pm-覆盖控制器","result":"对应Relay Switch 2pm覆盖控制器-固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Relay switch 2pm-覆盖控","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15975#AafJ5tj5][ble] 点击控制Relay switch 2pm-覆盖控 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"E5YRm4Pg","proto":"ble","name":"点击控制Garage Door Opener","result":"对应Garage Door Opener-固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Garage Door Opener","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15975#E5YRm4Pg][ble] 点击控制Garage Door Opener Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"QAHNrXrm","proto":"ble","name":"点击控制RGBICWW Strip Light (炫彩灯带)--开/关","result":"对应RGBICWW Strip Light (炫彩灯带)--固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制RGBICWW Strip Light ","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15975#QAHNrXrm][ble] 点击控制RGBICWW Strip Light StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"AfxmoAEq","proto":"ble","name":"点击控制RGBICWW Floor Lamp炫彩落地灯--开/关","result":"对应RGBICWW Floor Lamp炫彩落地灯--固件响应动作开/关","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制RGBICWW Floor Lamp炫","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15975#AfxmoAEq][ble] 点击控制RGBICWW Floor Lamp炫 CeilingLight 7Q","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"E2QCS8xy","proto":"ble","name":"点击控制RGBIC Neon Wire Rope Light炫彩钢丝霓虹灯--开/关","result":"对应RGBIC Neon Wire Rope Light炫彩钢丝霓虹灯--固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制RGBIC Neon Wire Rope","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15975#E2QCS8xy][ble] 点击控制RGBIC Neon Wire Rope StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"TSVKWcJ3","proto":"ble","name":"点击控制eu plug--开/关","result":"对应设备响应开/关","cat":"plug","device":"Plug 4D","action":"点击控制eu plug--开/关","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15975#TSVKWcJ3][ble] 点击控制eu plug--开/关 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15975,"step":"XWmNA9UL","proto":"ble","name":"Standing Circulator fan--首页控制开关","result":"对应落地扇响应开/关","cat":"fan","device":"Fan 0T","action":"Standing Circulator fan-","file":"tests/fan/fan_control.test.ts","testName":"[P0][ONES:15975#XWmNA9UL][ble] Standing Circulator fan- Fan 0T","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"Qt4hcgB4","proto":"wifi","name":"点击控制Bot 不加密开&不加密关&加密按压","result":"对应Bot固件响应动作","cat":"bot","device":"Bot 0F","action":"点击控制Bot 不加密开&不加密关&加密按压","file":"tests/bot/bot_control.test.ts","testName":"[P0][ONES:15974#Qt4hcgB4][wifi] 点击控制Bot 不加密开&不加密关&加密按压 Bot 0F","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"7cEkbh4Y","proto":"wifi","name":"点击控制Lock 开锁/解锁","result":"对应Lock固件响应动作开锁/解锁","cat":"lock","device":"Lock 6F","action":"点击控制Lock 开锁/解锁","file":"tests/lock/lock_control.test.ts","testName":"[P0][ONES:15974#7cEkbh4Y][wifi] 点击控制Lock 开锁/解锁 Lock 6F","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"GPT3wJd9","proto":"wifi","name":"Curtain---快捷弹窗---开/关","result":"对应Curtain固件响应动作开/关","cat":"curtain","device":"Curtain 1A","action":"Curtain---快捷弹窗---开/关","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15974#GPT3wJd9][wifi] Curtain---快捷弹窗---开/关 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"63kDVNx1","proto":"wifi","name":"Meter---历史数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"历史数据能够加载不会有特别异常的数据图表主页对应Meter温湿度字体变红色","cat":"meter","device":"Meter 3M","action":"Meter---历史数据---右上角设置---报","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15974#63kDVNx1][wifi] Meter---历史数据---右上角设置---报 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"8yGTCPwY","proto":"wifi","name":"Meter Plus---历史数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"历史数据能够加载不会有特别异常的数据图表主页对应Meter Plus温湿度字体变红色","cat":"meter","device":"Meter 3M","action":"Meter Plus---历史数据---右上角设","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15974#8yGTCPwY][wifi] Meter Plus---历史数据---右上角设 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"Ay79Pj23","proto":"wifi","name":"点击控制Plug 开/关","result":"对应Plug固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Plug 开/关","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15974#Ay79Pj23][wifi] 点击控制Plug 开/关 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"8tx3Rg76","proto":"wifi","name":"Humidifier---快捷弹窗---开启加湿器自动/低/中/高档/关闭","result":"对应Humidifier固件响应动作开启加湿器自动/低/中/高档/关闭","cat":"humidifier","device":"Humidifier 1K","action":"Humidifier---快捷弹窗---开启加湿","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15974#8tx3Rg76][wifi] Humidifier---快捷弹窗---开启加湿 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"Kaj2UdKf","proto":"wifi","name":"点击控制Bulb 开/关","result":"对应Bulb固件响应动作开/关","cat":"color_bulb","device":"ColorBulb 8R","action":"点击控制Bulb 开/关","file":"tests/color_bulb/color_bulb_control.test.ts","testName":"[P0][ONES:15974#Kaj2UdKf][wifi] 点击控制Bulb 开/关 ColorBulb 8R","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"QgX4dX7X","proto":"wifi","name":"点击控制Strip Light开/关","result":"对应Strip Light固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制Strip Light开/关","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15974#QgX4dX7X][wifi] 点击控制Strip Light开/关 StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"3nXsFW9n","proto":"wifi","name":"点击控制Plug Mini开/关","result":"对应Plug Mini固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Plug Mini开/关","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15974#3nXsFW9n][wifi] 点击控制Plug Mini开/关 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"N7fSHmH3","proto":"wifi","name":"Motion Sensor---功能页面在Motion Sensor固件前移动","result":"功能页面正常显示有人移动","cat":"sensor","device":"Contact Sensor 5O","action":"Motion Sensor---功能页面在Mo","file":"tests/sensor/sensor_control.test.ts","testName":"[P0][ONES:15974#N7fSHmH3][wifi] Motion Sensor---功能页面在Mo Contact Sensor 5O","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"JKSgHHbC","proto":"wifi","name":"Contact Sensor---功能页面开关Contact Sensor固件","result":"功能页面正常显示Contact Sensor的开关状态","cat":"sensor","device":"Contact Sensor 5O","action":"Contact Sensor---功能页面,开关","file":"tests/sensor/sensor_control.test.ts","testName":"[P0][ONES:15974#JKSgHHbC][wifi] Contact Sensor---功能页面,开关 Contact Sensor 5O","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"4oeeJEV8","proto":"wifi","name":"点击Indoor Cam出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流","cat":"camera","device":"摄像机Plus 3K","action":"点击Indoor Cam出流成功后在页面停留3","file":"tests/camera/camera_control.test.ts","testName":"[P0][ONES:15974#4oeeJEV8][wifi] 点击Indoor Cam出流成功后在页面停留3 摄像机Plus 3K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"16szaUdD","proto":"wifi","name":"点击Pan/Tilt Cam出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流,画面显示正常,时间水印正常跳动","cat":"camera","device":"摄像机Plus 3K","action":"点击Pan/Tilt Cam出流成功后在页面停","file":"tests/camera/camera_control.test.ts","testName":"[P0][ONES:15974#16szaUdD][wifi] 点击Pan/Tilt Cam出流成功后在页面停 摄像机Plus 3K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"Hj5Z9jSf","proto":"wifi","name":"点击Pan/Tilt Cam 2K出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流,画面显示正常,时间水印正常跳动","cat":"camera","device":"摄像机Plus 3K","action":"点击Pan/Tilt Cam 2K出流成功后在","file":"tests/camera/camera_control.test.ts","testName":"[P0][ONES:15974#Hj5Z9jSf][wifi] 点击Pan/Tilt Cam 2K出流成功后在 摄像机Plus 3K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"SaKmFWLw","proto":"wifi","name":"点击Pan/Tilt Cam Plus 3MP出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流,画面显示正常,时间水印正常跳动","cat":"camera","device":"摄像机Plus 3K","action":"点击Pan/Tilt Cam Plus 3MP","file":"tests/camera/camera_control.test.ts","testName":"[P0][ONES:15974#SaKmFWLw][wifi] 点击Pan/Tilt Cam Plus 3MP 摄像机Plus 3K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"LDqSDPPT","proto":"wifi","name":"点击Pan/Tilt Cam Plus 5MP出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流,画面显示正常,时间水印正常跳动","cat":"camera","device":"摄像机Plus 3K","action":"点击Pan/Tilt Cam Plus 5MP","file":"tests/camera/camera_control.test.ts","testName":"[P0][ONES:15974#LDqSDPPT][wifi] 点击Pan/Tilt Cam Plus 5MP 摄像机Plus 3K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"HoDdVUm1","proto":"wifi","name":"点击OSC出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流,画面显示正常,时间水印正常跳动","cat":"osc","device":"OSC 2K","action":"点击OSC出流成功后在页面停留3min","file":"tests/osc/osc_control.test.ts","testName":"[P0][ONES:15974#HoDdVUm1][wifi] 点击OSC出流成功后在页面停留3min OSC 2K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"WH7kyypF","proto":"wifi","name":"点击OSC 2K出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流,画面显示正常,时间水印正常跳动","cat":"osc","device":"OSC 2K","action":"点击OSC 2K出流成功后在页面停留3min","file":"tests/osc/osc_control.test.ts","testName":"[P0][ONES:15974#WH7kyypF][wifi] 点击OSC 2K出流成功后在页面停留3min OSC 2K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"J8BQ1p9G","proto":"wifi","name":"点击Doorbell出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流,画面显示正常,时间水印正常跳动","cat":"camera","device":"摄像机Plus 3K","action":"点击Doorbell出流成功后在页面停留3mi","file":"tests/camera/camera_control.test.ts","testName":"[P0][ONES:15974#J8BQ1p9G][wifi] 点击Doorbell出流成功后在页面停留3mi 摄像机Plus 3K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"KNk1HaB2","proto":"wifi","name":"点击Outdoor PTC出流成功后在页面停留3min","result":"跳转功能页面,能够正常加载视频流,画面显示正常,时间水印正常跳动","cat":"camera","device":"摄像机Plus 3K","action":"点击Outdoor PTC出流成功后在页面停留","file":"tests/camera/camera_control.test.ts","testName":"[P0][ONES:15974#KNk1HaB2][wifi] 点击Outdoor PTC出流成功后在页面停留 摄像机Plus 3K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"67oRHoYJ","proto":"wifi","name":"点击Robot Vacuum S1控制清扫、暂停、回充","result":"对应Robot Vacuum S1固件执行清扫、暂停、回充","cat":"robot","device":"Robot S1","action":"点击Robot Vacuum S1控制清扫、暂停","file":"tests/robot/robot_control.test.ts","testName":"[P0][ONES:15974#67oRHoYJ][wifi] 点击Robot Vacuum S1控制清扫、暂停 Robot S1","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"YAeiQE6n","proto":"wifi","name":"点击Robot Vacuum S1 Plus控制清扫、暂停、回充","result":"对应Robot Vacuum S1 Plus固件执行清扫、暂停、回充","cat":"robot","device":"Robot S1","action":"点击Robot Vacuum S1 Plus控制","file":"tests/robot/robot_control.test.ts","testName":"[P0][ONES:15974#YAeiQE6n][wifi] 点击Robot Vacuum S1 Plus控制 Robot S1","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"TcnL3gFM","proto":"wifi","name":"点击控制吸顶灯pro开/关灯","result":"对应吸顶灯pro响应动作开/关灯","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制吸顶灯pro开/关灯","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15974#TcnL3gFM][wifi] 点击控制吸顶灯pro开/关灯 CeilingLight 7Q","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"WL8KSQLb","proto":"wifi","name":"点击控制吸顶灯lte开/关灯","result":"对应吸顶灯lte响应动作开/关灯","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制吸顶灯lte开/关灯","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15974#WL8KSQLb][wifi] 点击控制吸顶灯lte开/关灯 CeilingLight 7Q","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"6gv1WLKE","proto":"wifi","name":"首页点击控制固件V20版本BlindTilt","result":"对应BlindTilt固件响应","cat":"curtain","device":"Curtain 1A","action":"首页点击控制固件V20版本BlindTilt","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15974#6gv1WLKE][wifi] 首页点击控制固件V20版本BlindTilt Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"B5N4u7Fj","proto":"wifi","name":"首页点击控制固件V12版本BlindTil功能页按钮验证兼容旧版本","result":"对应BlindTilt固件响应","cat":"curtain","device":"Curtain 1A","action":"首页点击控制固件V12版本BlindTil功能页","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15974#B5N4u7Fj][wifi] 首页点击控制固件V12版本BlindTil功能页 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"5yYJmwLL","proto":"wifi","name":"IOSensor---历史数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"历史数据能够加载不会有特别异常的数据图表主页对应IOSensor温湿度字体变红色","cat":"sensor","device":"Contact Sensor 5O","action":"IOSensor---历史数据---右上角设置-","file":"tests/sensor/sensor_control.test.ts","testName":"[P0][ONES:15974#5yYJmwLL][wifi] IOSensor---历史数据---右上角设置- Contact Sensor 5O","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"GYtvdMTW","proto":"wifi","name":"Curtain 3---快捷弹窗---开/关","result":"对应Curtain 3固件响应动作开/关","cat":"curtain","device":"Curtain 1A","action":"Curtain 3---快捷弹窗---开/关","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15974#GYtvdMTW][wifi] Curtain 3---快捷弹窗---开/关 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"44FEY1z2","proto":"wifi","name":"点击K10+ 扫地机控制清扫、暂停、回充","result":"对应扫地机执行清扫、暂停、回充","cat":"robot","device":"Robot S1","action":"点击K10+ 扫地机控制清扫、暂停、回充","file":"tests/robot/robot_control.test.ts","testName":"[P0][ONES:15974#44FEY1z2][wifi] 点击K10+ 扫地机控制清扫、暂停、回充 Robot S1","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"6RC22BGo","proto":"wifi","name":"带电池款Battery Circulator Fan---首页控制开关","result":"对应循环扇开关","cat":"fan","device":"Fan 0T","action":"带电池款Battery Circulator F","file":"tests/fan/fan_control.test.ts","testName":"[P0][ONES:15974#6RC22BGo][wifi] 带电池款Battery Circulator F Fan 0T","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"Virku9zd","proto":"wifi","name":"点击控制Lock Pro开锁/解锁","result":"对应Lock Pro固件响应动作开锁/解锁","cat":"lock","device":"Lock 6F","action":"点击控制Lock Pro开锁/解锁","file":"tests/lock/lock_control.test.ts","testName":"[P0][ONES:15974#Virku9zd][wifi] 点击控制Lock Pro开锁/解锁 Lock 6F","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"BhNuXgg7","proto":"wifi","name":"进入Humidifier2设置页远程绑定温湿度计点击切换档位、模式","result":"绑定成功,功能页显示对应湿度值;切换档位、模式、目标湿度正常。","cat":"humidifier","device":"Humidifier 1K","action":"进入Humidifier2设置页远程绑定温湿度","file":"tests/humidifier/humidifier_control.test.ts","testName":"[P0][ONES:15974#BhNuXgg7][wifi] 进入Humidifier2设置页远程绑定温湿度 Humidifier 1K","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"9bcyjgJD","proto":"wifi","name":"点击S10控制清扫、暂停、回充","result":"对应S10固件执行清扫、暂停、回充","cat":"robot","device":"Robot S1","action":"点击S10控制清扫、暂停、回充","file":"tests/robot/robot_control.test.ts","testName":"[P0][ONES:15974#9bcyjgJD][wifi] 点击S10控制清扫、暂停、回充 Robot S1","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"KNyzKhn6","proto":"wifi","name":"无电池款 Circulator Fan---首页控制开关","result":"对应循环扇开关","cat":"fan","device":"Fan 0T","action":"无电池款 Circulator Fan---首页","file":"tests/fan/fan_control.test.ts","testName":"[P0][ONES:15974#KNyzKhn6][wifi] 无电池款 Circulator Fan---首页 Fan 0T","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"W7nK4VEB","proto":"wifi","name":"点击K10+Pro控制清扫、暂停、回充","result":"K10+ Pro正常响应清扫、暂停、回充","cat":"robot","device":"Robot S1","action":"点击K10+Pro控制清扫、暂停、回充","file":"tests/robot/robot_control.test.ts","testName":"[P0][ONES:15974#W7nK4VEB][wifi] 点击K10+Pro控制清扫、暂停、回充 Robot S1","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"GWAbic5S","proto":"wifi","name":"点击K10+ProCombo控制清扫、暂停、回充","result":"K10+ ProCombo正常响应清扫、暂停、回充","cat":"robot","device":"Robot S1","action":"点击K10+ProCombo控制清扫、暂停、回充","file":"tests/robot/robot_control.test.ts","testName":"[P0][ONES:15974#GWAbic5S][wifi] 点击K10+ProCombo控制清扫、暂停、回充 Robot S1","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"ScBpcSfJ","proto":"wifi","name":"Roller Shade功能页控制设备全开、暂停、全关、任意百分比控制","result":"Roller Shade正常响应所有控制指令","cat":"curtain","device":"Curtain 1A","action":"Roller Shade功能页控制设备全开、暂停","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15974#ScBpcSfJ][wifi] Roller Shade功能页控制设备全开、暂停 Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"AWVhA6za","proto":"wifi","name":"点击控制美规基础款空气净化器--开/关","result":"对应美规基础款空气净化器--开关","cat":"air_condition","device":"AirCondition IR","action":"点击控制美规基础款空气净化器--开/关","file":"tests/air_condition/air_condition_control.test.ts","testName":"[P0][ONES:15974#AWVhA6za][wifi] 点击控制美规基础款空气净化器--开/关 AirCondition IR","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"MmodMTvh","proto":"wifi","name":"点击控制日规Table空气净化器--开/关","result":"对应日规Table空气净化器--开/关","cat":"air_condition","device":"AirCondition IR","action":"点击控制日规Table空气净化器--开/关","file":"tests/air_condition/air_condition_control.test.ts","testName":"[P0][ONES:15974#MmodMTvh][wifi] 点击控制日规Table空气净化器--开/关 AirCondition IR","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"VbqWg59G","proto":"wifi","name":"Curtain 3 2025---快捷弹窗---开/关","result":"对应Curtain 3 2025固件响应动作开/关","cat":"curtain","device":"Curtain 1A","action":"Curtain 3 2025---快捷弹窗---","file":"tests/curtain/curtain_control.test.ts","testName":"[P0][ONES:15974#VbqWg59G][wifi] Curtain 3 2025---快捷弹窗--- Curtain 1A","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"N5i3oQP2","proto":"wifi","name":"Meter Pro---图表数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"图表数据能够加载不会有特别异常的数据图表主页对应Meter Pro温湿度字体变红色","cat":"meter","device":"Meter 3M","action":"Meter Pro---图表数据---右上角设置","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15974#N5i3oQP2][wifi] Meter Pro---图表数据---右上角设置 Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"LAtdvNBV","proto":"wifi","name":"Meter Pro co2\n---图表数据---右上角设置---报警条件,开启温度和湿度报警,拉动滑条使当前温湿度在报警范围内","result":"图表数据能够加载不会有特别异常的数据图表主页对应Meter Pro co2温湿度字体变红色","cat":"meter","device":"Meter 3M","action":"Meter Pro co2 ---图表数据---","file":"tests/meter/meter_control.test.ts","testName":"[P0][ONES:15974#LAtdvNBV][wifi] Meter Pro co2 ---图表数据--- Meter 3M","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"N1kzcb7S","proto":"wifi","name":"点击控制RGBWW 彩色灯带3 --开/关","result":"对应RGBWW 彩色灯带3 --固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制RGBWW 彩色灯带3 --开/关","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15974#N1kzcb7S][wifi] 点击控制RGBWW 彩色灯带3 --开/关 StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"2hm6sXrk","proto":"wifi","name":"点击控制RGBWW 落地灯 --开/关","result":"对应RGBWW 落地灯 --固件响应动作开/关","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制RGBWW 落地灯 --开/关","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15974#2hm6sXrk][wifi] 点击控制RGBWW 落地灯 --开/关 CeilingLight 7Q","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"XvFF8AK7","proto":"wifi","name":"点击控制Relay Switch 1pm--开/关","result":"对应Relay Switch 1pm --固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Relay Switch 1pm--开/","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15974#XvFF8AK7][wifi] 点击控制Relay Switch 1pm--开/ Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"Y9gosqbQ","proto":"wifi","name":"点击控制Relay Switch 1--开/关","result":"对应Relay Switch 1 --固件响应动作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Relay Switch 1--开/关","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15974#Y9gosqbQ][wifi] 点击控制Relay Switch 1--开/关 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"Vro1QBrg","proto":"wifi","name":"点击控制Relay Switch 2pm开关控制器--开/关","result":"对应 --固件响应Relay Switch 2pm开关控制器响作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Relay Switch 2pm开关控制","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15974#Vro1QBrg][wifi] 点击控制Relay Switch 2pm开关控制 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"c34Tk92u","proto":"wifi","name":"点击控制Relay Switch 2pm覆盖控制器--开/关","result":"对应 --固件响应动Relay Switch 2pm覆盖控制器响作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Relay Switch 2pm覆盖控制","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15974#c34Tk92u][wifi] 点击控制Relay Switch 2pm覆盖控制 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"2nRaC87F","proto":"wifi","name":"点击控制Garage Door Opener--开/关","result":"对应 --固件响应Garage Door Opener动响作开/关","cat":"plug","device":"Plug 4D","action":"点击控制Garage Door Opener--","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15974#2nRaC87F][wifi] 点击控制Garage Door Opener-- Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"MAwYyCaC","proto":"wifi","name":"点击控制RGBICWW Strip Light (炫彩灯带)--开/关","result":"对应RGBICWW Strip Light (炫彩灯带)--固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制RGBICWW Strip Light ","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15974#MAwYyCaC][wifi] 点击控制RGBICWW Strip Light StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"ML8CSkWX","proto":"wifi","name":"点击控制RGBICWW Floor Lamp炫彩落地灯--开/关","result":"对应RGBICWW Floor Lamp炫彩落地灯--固件响应动作开/关","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制RGBICWW Floor Lamp炫","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15974#ML8CSkWX][wifi] 点击控制RGBICWW Floor Lamp炫 CeilingLight 7Q","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"5sJcsTgi","proto":"wifi","name":"点击控制RGBIC Neon Wire Rope Light炫彩钢丝霓虹灯--开/关","result":"对应RGBIC Neon Wire Rope Light炫彩钢丝霓虹灯--固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制RGBIC Neon Wire Rope","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15974#5sJcsTgi][wifi] 点击控制RGBIC Neon Wire Rope StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"U5z9wocr","proto":"wifi","name":"点击控制eu plug--开/关","result":"对应设备响应开/关","cat":"plug","device":"Plug 4D","action":"点击控制eu plug--开/关","file":"tests/plug/plug_control.test.ts","testName":"[P0][ONES:15974#U5z9wocr][wifi] 点击控制eu plug--开/关 Plug 4D","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"BiEXxZDr","proto":"wifi","name":"点击控制RGBIC Neon Rope Light炫彩霓虹灯--开/关","result":"对应RGBIC Neon Rope Light炫彩霓虹灯--固件响应动作开/关","cat":"strip_light","device":"StripLight 9S","action":"点击控制RGBIC Neon Rope Ligh","file":"tests/strip_light/strip_light_control.test.ts","testName":"[P0][ONES:15974#BiEXxZDr][wifi] 点击控制RGBIC Neon Rope Ligh StripLight 9S","status":"todo"},
{"kind":"ctrl","ones":15974,"step":"RXm3iNLR","proto":"wifi","name":"点击控制Candle lamp融蜡灯--开/关融蜡灯需要插110V电压","result":"对应Candle lamp融蜡灯--固件响应动作开/关","cat":"ceiling_light","device":"CeilingLight 7Q","action":"点击控制Candle lamp融蜡灯--开/","file":"tests/ceiling_light/ceiling_light_control.test.ts","testName":"[P0][ONES:15974#RXm3iNLR][wifi] 点击控制Candle lamp融蜡灯--开/ CeilingLight 7Q","status":"todo"},
];

View File

@ -1,607 +0,0 @@
{
"_note": "必测项回写参数。用例固定仅新增;回写时 plan UUID 为变量。key 规则: case=testcase_plan_case-<plan>-<caseUUID>; step=testcase_plan_case_step-<plan>-<caseUUID>-<stepUuid>。刷新: 重跑 scripts/gen-writeback-params.ts。",
"defaultPlan": "CQz9YCNX",
"cases": [
{
"number": 15940,
"uuid": "6yxCKHLc",
"name": "账户注册验证"
},
{
"number": 15941,
"uuid": "9TYHacGi",
"name": "忘记密码验证"
},
{
"number": 15942,
"uuid": "79wW7aGj",
"name": "第三方登录"
},
{
"number": 15943,
"uuid": "61CCdeGk",
"name": "账户登出验证"
},
{
"number": 15944,
"uuid": "6KfLxzGc",
"name": "创建房间验证"
},
{
"number": 15945,
"uuid": "SuNW9m9B",
"name": "feedback提交验证"
},
{
"number": 15946,
"uuid": "3eHivSnk",
"name": "添加Blind Tilt验证"
},
{
"number": 15947,
"uuid": "2s4GbZAh",
"name": "添加Pan/Tilt Cam 2K验证"
},
{
"number": 15948,
"uuid": "YCHSGAPP",
"name": "添加keypad touch验证"
},
{
"number": 15949,
"uuid": "KDcB3xju",
"name": "添加keypad验证"
},
{
"number": 15950,
"uuid": "PhJTMDYV",
"name": "添加吸顶灯验证Pro和lte"
},
{
"number": 15951,
"uuid": "TBp2WxHN",
"name": "添加Robot Vacuum S1 Plus验证"
},
{
"number": 15952,
"uuid": "EGzyeS4Z",
"name": "添加Robot Vacuum S1验证"
},
{
"number": 15953,
"uuid": "4zJjZbQr",
"name": "学习others遥控器"
},
{
"number": 15954,
"uuid": "KHUtwWFg",
"name": "学习自定义遥控器"
},
{
"number": 15955,
"uuid": "ASgigVHm",
"name": "智能匹配遥控器"
},
{
"number": 15956,
"uuid": "5ujCPVBF",
"name": "添加Meter Plus验证"
},
{
"number": 15957,
"uuid": "7KAwsNmC",
"name": "添加Pan/Tilt Cam验证"
},
{
"number": 15958,
"uuid": "GdqgjWmu",
"name": "添加Plug Mini验证JP和US"
},
{
"number": 15959,
"uuid": "PgeMXwx6",
"name": "添加Strip Light验证"
},
{
"number": 15960,
"uuid": "XQGshXkv",
"name": "添加Color Bulb验证"
},
{
"number": 15961,
"uuid": "VaxZgabA",
"name": "添加Indoor Cam验证"
},
{
"number": 15962,
"uuid": "VVTCNWiX",
"name": "添加Contact Sensor验证"
},
{
"number": 15963,
"uuid": "U45e5n66",
"name": "添加Motion Sensor验证"
},
{
"number": 15964,
"uuid": "JjvbRHYU",
"name": "添加Humidifier验证"
},
{
"number": 15965,
"uuid": "8UFaR2qu",
"name": "添加Meter验证"
},
{
"number": 15966,
"uuid": "36y6sC3z",
"name": "添加Plug验证"
},
{
"number": 15967,
"uuid": "L4C4AVQe",
"name": "添加Remote验证"
},
{
"number": 15968,
"uuid": "K6LFdz81",
"name": "添加Bot验证"
},
{
"number": 15969,
"uuid": "3XahnWS4",
"name": "添加Curtain验证"
},
{
"number": 15970,
"uuid": "SKAj9t7t",
"name": "添加Lock验证"
},
{
"number": 15971,
"uuid": "NSQT1PGJ",
"name": "添加Hub Plus验证"
},
{
"number": 15972,
"uuid": "Fi2V2XPY",
"name": "添加Hub Mini验证"
},
{
"number": 15973,
"uuid": "SP75BSp2",
"name": "keypad、keypad touch添加密码、指纹、卡片验证"
},
{
"number": 15974,
"uuid": "Vp7vuhbu",
"name": "WiFi控制设备"
},
{
"number": 15975,
"uuid": "Lqpkx6mp",
"name": "蓝牙控制设备"
},
{
"number": 15976,
"uuid": "6qQ6Pmm2",
"name": "场景操作验证"
},
{
"number": 15977,
"uuid": "Be91sD22",
"name": "第三方操作验证"
},
{
"number": 15978,
"uuid": "76VscU5y",
"name": "覆盖安装测试"
},
{
"number": 21787,
"uuid": "SF9cqxki",
"name": "添加Hub2 验证"
},
{
"number": 35871,
"uuid": "PRvriHQj",
"name": "添加IOSensor验证"
},
{
"number": 40954,
"uuid": "8GpJwfuo",
"name": "添加K10+扫地机"
},
{
"number": 40955,
"uuid": "y9ksiDTo",
"name": "添加curtain 3验证"
},
{
"number": 47214,
"uuid": "L3dTTpJU",
"name": "添加PTC Plus 3MP"
},
{
"number": 48159,
"uuid": "Dh4gScBV",
"name": "添加Battery Circulator Fan验证"
},
{
"number": 48734,
"uuid": "Cvu7v7dt",
"name": "插件热更"
},
{
"number": 51440,
"uuid": "5F6H4XW4",
"name": "添加lock&kit验证"
},
{
"number": 65336,
"uuid": "9CT8TcY4",
"name": "添加Hub Mini Matter验证"
},
{
"number": 66694,
"uuid": "CNK9w5gf",
"name": "添加Lock Pro验证"
},
{
"number": 74078,
"uuid": "g7y2EGXF",
"name": "APP切换温湿度单位显示"
},
{
"number": 74104,
"uuid": "PJ3bJLTg",
"name": "添加Humidifier2验证"
},
{
"number": 74105,
"uuid": "XTNTvNyF",
"name": "强绑定设备的自动解绑申请"
},
{
"number": 78369,
"uuid": "7mrfzZr1",
"name": "添加S10扫地机"
},
{
"number": 105436,
"uuid": "D2c4eLE5",
"name": "添加lock Pro&kit验证"
},
{
"number": 113941,
"uuid": "B7CRfHh4",
"name": "添加OSC验证"
},
{
"number": 113942,
"uuid": "5JYiBYff",
"name": "添加OSC 2K验证"
},
{
"number": 113943,
"uuid": "S4f2GnKM",
"name": "添加PTC Plus 5MP"
},
{
"number": 122737,
"uuid": "9aCBMCZM",
"name": "添加K10+Pro扫地机"
},
{
"number": 122775,
"uuid": "svdK6AdD",
"name": "添加 Circulator Fan验证无电池款"
},
{
"number": 162261,
"uuid": "2E5kn5TV",
"name": "添加K10+ Pro Combo"
},
{
"number": 162263,
"uuid": "JCqQmM1y",
"name": "K10+ Pro Combo绑定手持吸尘器"
},
{
"number": 162264,
"uuid": "UvH2Zoiw",
"name": "K10+ Pro Combo解除绑定手持"
},
{
"number": 190801,
"uuid": "XeoBqvEX",
"name": "添加URC验证"
},
{
"number": 190802,
"uuid": "WcEHdNnS",
"name": "同步URC设备、控制"
},
{
"number": 191100,
"uuid": "LiFS1jPW",
"name": "添加Roller Shade验证"
},
{
"number": 196802,
"uuid": "HhrjuktG",
"name": "添加空气净化器验证基础款和table款"
},
{
"number": 197389,
"uuid": "SZZq5NDW",
"name": "添加Doorbell"
},
{
"number": 205248,
"uuid": "4jt45mtk",
"name": "添加S20扫地机"
},
{
"number": 227290,
"uuid": "XY3yL3yL",
"name": "欧区账号登录"
},
{
"number": 265933,
"uuid": "T4C5Bc4h",
"name": "添加curtain 3 2025验证"
},
{
"number": 267366,
"uuid": "8h8m2oMK",
"name": "添加Meter pro co2验证"
},
{
"number": 267367,
"uuid": "AdAxoQss",
"name": "添加Meter pro验证"
},
{
"number": 273787,
"uuid": "4qEc6uDh",
"name": "添加K11+扫地机"
},
{
"number": 298968,
"uuid": "YSyKzd5b",
"name": "消息中心"
},
{
"number": 301585,
"uuid": "MaLqK2LR",
"name": "添加RGBWW Strip light3(彩色灯带3 验证 "
},
{
"number": 301586,
"uuid": "DXWPJi2t",
"name": "添加RGBWW Floor Lamp (落地灯)验证"
},
{
"number": 302288,
"uuid": "ABWcTyTS",
"name": "添加Relay Switch验证"
},
{
"number": 302289,
"uuid": "SwhcmiAT",
"name": "添加Garage Door Opener验证"
},
{
"number": 304130,
"uuid": "5EZBMbyC",
"name": "添加Lock Ultra验证"
},
{
"number": 304131,
"uuid": "8FytfzPz",
"name": "添加Keypad Vision验证"
},
{
"number": 304132,
"uuid": "CquFqWd6",
"name": "添加Lock Lite验证"
},
{
"number": 304133,
"uuid": "UtDXaxef",
"name": "添加Lock Pro WiFi验证"
},
{
"number": 310703,
"uuid": "GRuid7nr",
"name": "添加Climate Panel验证"
},
{
"number": 310704,
"uuid": "4e4Anr8B",
"name": "添加暖气阀Smart Radiator Thermostat验证"
},
{
"number": 311018,
"uuid": "Evr4zoiU",
"name": "添加RGBICWW Strip Light炫彩灯带 验证"
},
{
"number": 311019,
"uuid": "2mnWm4kU",
"name": "添加RGBICWW Floor Lamp炫彩落地灯验证"
},
{
"number": 311020,
"uuid": "NrBUAnYg",
"name": "添加RGBIC Neon Wire Rope Light炫彩钢丝霓虹灯验证"
},
{
"number": 319303,
"uuid": "3ibhGBWz",
"name": "添加Hub Mini Matter S3验证"
},
{
"number": 319304,
"uuid": "Vu6x1gpU",
"name": "添加Hub2 S3验证"
},
{
"number": 319305,
"uuid": "2qvq8JZa",
"name": "添加Hub Mini S3验证"
},
{
"number": 332917,
"uuid": "8UFZSbeC",
"name": "添加人体存在传感器"
},
{
"number": 332918,
"uuid": "Wmyuosou",
"name": "添加eu plug"
},
{
"number": 332919,
"uuid": "8CXeNBcG",
"name": "添加safety alarm"
},
{
"number": 393234,
"uuid": "9t9W6kLx",
"name": "添加Standing Circulator fan验证"
},
{
"number": 393235,
"uuid": "87y8Y8Ni",
"name": "添加RGBIC Neon Rope Light炫彩霓虹灯验证"
},
{
"number": 393236,
"uuid": "DAFQxUYA",
"name": "添加Candle lamp融蜡灯验证融蜡灯需要插110V电压"
}
],
"controlCases": {
"15974": {
"name": "WiFi控制设备",
"uuid": "Vp7vuhbu",
"proto": "wifi",
"steps": [
"Qt4hcgB4",
"7cEkbh4Y",
"GPT3wJd9",
"63kDVNx1",
"8yGTCPwY",
"Ay79Pj23",
"8tx3Rg76",
"Kaj2UdKf",
"QgX4dX7X",
"3nXsFW9n",
"N7fSHmH3",
"JKSgHHbC",
"4oeeJEV8",
"16szaUdD",
"Hj5Z9jSf",
"SaKmFWLw",
"LDqSDPPT",
"HoDdVUm1",
"WH7kyypF",
"J8BQ1p9G",
"KNk1HaB2",
"67oRHoYJ",
"YAeiQE6n",
"TcnL3gFM",
"WL8KSQLb",
"6gv1WLKE",
"B5N4u7Fj",
"5yYJmwLL",
"GYtvdMTW",
"44FEY1z2",
"6RC22BGo",
"Virku9zd",
"BhNuXgg7",
"9bcyjgJD",
"KNyzKhn6",
"W7nK4VEB",
"GWAbic5S",
"ScBpcSfJ",
"AWVhA6za",
"MmodMTvh",
"VbqWg59G",
"N5i3oQP2",
"LAtdvNBV",
"N1kzcb7S",
"2hm6sXrk",
"XvFF8AK7",
"Y9gosqbQ",
"Vro1QBrg",
"c34Tk92u",
"2nRaC87F",
"MAwYyCaC",
"ML8CSkWX",
"5sJcsTgi",
"U5z9wocr",
"BiEXxZDr",
"RXm3iNLR"
]
},
"15975": {
"name": "蓝牙控制设备",
"uuid": "Lqpkx6mp",
"proto": "ble",
"steps": [
"6e4uZSVe",
"5YEfmpJz",
"774V8bND",
"NvpUv9Tt",
"3Jm5FAdW",
"QfYLdj8s",
"GkkrZpaC",
"TNhSAuUC",
"4poyiVwD",
"2528azD6",
"V15M64cP",
"2jSFfY4k",
"UZCtZRPy",
"9mheLMDJ",
"GSZv8BYm",
"HrigrD2X",
"A8C4BsCm",
"3frMRDZj",
"Qbm9VB72",
"QwnZJARA",
"Ncg7BDeR",
"k6cqKqtZ",
"QqsnPMc3",
"CiFPEbNS",
"LnPNEJmS",
"3PmZMk1s",
"JBszgxRS",
"SmzuHtWi",
"7qxA1ajE",
"R5PDp8BR",
"3wM6fW8W",
"JggzNXDo",
"L967RGHa",
"8Wzaj5pB",
"3bYsmiZG",
"NDqRQxVx",
"DxKG9Acs",
"3ob75cjf",
"JVnRz7xY",
"MKgLq1oy",
"A1TLxsFw",
"PG62s725",
"AafJ5tj5",
"E5YRm4Pg",
"QAHNrXrm",
"AfxmoAEq",
"E2QCS8xy",
"TSVKWcJ3",
"XWmNA9UL"
]
}
}
}

View File

@ -133,7 +133,7 @@ describe('AIHub Connect - 添加AI Hub设备', () => {
const alreadyExists = await isDeviceOnHomepage(driver, AIHUB_NAME);
if (alreadyExists) {
console.log(`${AIHUB_NAME}已在首页,跳过重新添加`);
reporter.record(`添加${AIHUB_NAME}`, 'PASS', Date.now() - start, `${AIHUB_NAME}已存在, 无需重新添加`);
reporter.record(`添加${AIHUB_NAME}`, 'SKIP', Date.now() - start, `${AIHUB_NAME}已存在, 无需重新添加`);
return;
}

View File

@ -46,7 +46,7 @@ describe('Air Condition Connect - 添加红外空调设备', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record('[P0][ONES:196802] 添加红外空调设备', 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record('[P0][ONES:196802] 添加红外空调设备', 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}

View File

@ -259,11 +259,11 @@ describe('【通用自动化】- 创建/删除流程', () => {
await logPageElements(pageSrc);
steps.push('Add后页面元素已打印');
reporter.record('点击Add发现创建页面', 'PASS', Date.now() - start, steps.join(' → '));
reporter.record('[P0][ONES:15976] 场景操作-点击Add发现创建页面', 'PASS', Date.now() - start, steps.join(' → '));
} catch (e: any) {
const src = await driver.getSource();
await logPageElements(src);
reporter.record('点击Add发现创建页面', 'FAIL', Date.now() - start, e.message);
reporter.record('[P0][ONES:15976] 场景操作-点击Add发现创建页面', 'FAIL', Date.now() - start, e.message);
throw e;
}
});

View File

@ -0,0 +1,73 @@
import { describe, it, beforeAll, afterAll, expect } from 'vitest';
import { TestReporter } from '../../utils/test-reporter';
import { getTicketRnPlugins, resolveTicketId } from '../../utils/common';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
/**
* RN插件 Zendesk app RN (Bundle_Version)
*
* API , driver****,:
* /env ZENDESK_TICKET_ID (feedback )setSharedTicketId()
* Zendesk SKIP(便)
*
* ONES 锚点:插件热更 48734( EPfZfC9Y / Eeme1qVk) env ONES_RN_PLUGIN
*/
const ONES = `[P0][ONES:${process.env.ONES_RN_PLUGIN || '48734'}]`;
describe('【平台】插件热更 - 获取RN插件版本(工单 Bundle_Version)', () => {
let reporter: TestReporter;
beforeAll(() => {
reporter = new TestReporter('RN_Plugin', 'API');
});
afterAll(() => {
reporter.generate();
});
it(`${ONES} 获取RN插件版本`, async () => {
const start = Date.now();
const name = `${ONES} 获取RN插件版本`;
// 前置:工单号 + Zendesk 连接;缺任一则 SKIP(无人值守安全)
let ticketId: string;
try {
ticketId = resolveTicketId(process.env.ZENDESK_TICKET_ID);
} catch (e: any) {
console.log('跳过:', e.message);
reporter.record(name, 'SKIP', Date.now() - start, e.message);
return;
}
if (!process.env.ZENDESK_DOMAIN || !process.env.ZENDESK_TOKEN) {
const msg = '跳过:未配置 ZENDESK_DOMAIN/ZENDESK_TOKEN';
console.log(msg);
reporter.record(name, 'SKIP', Date.now() - start, msg);
return;
}
try {
const info = await getTicketRnPlugins(ticketId);
// 断言:工单评论里取到了 Bundle_Version,且解析出至少一个 RN 插件模块
expect(info.bundleRaw).not.toBeNull();
const modules = Object.keys(info.bundle);
expect(modules.length).toBeGreaterThan(0);
const detail = [
`ticket=${info.ticketId}`,
`App=${info.appVersion ?? '-'}`,
`JS=${info.jsVersion ?? '-'}`,
`RN插件(${modules.length}): ` + modules.map((m) => `${m}:${info.bundle[m]}`).join(' / '),
].join(' | ');
console.log(detail);
reporter.record(name, 'PASS', Date.now() - start, detail);
} catch (e: any) {
reporter.record(name, 'FAIL', Date.now() - start, e.message);
throw e;
}
});
});

BIN
tests/bot/.DS_Store vendored

Binary file not shown.

View File

@ -37,7 +37,7 @@ describe('Bot Connect - 添加Bot设备', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record(`[P0][ONES:15968] 添加${deviceName}`, 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`[P0][ONES:15968] 添加${deviceName}`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}

View File

@ -27,6 +27,8 @@ import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('ceilingLight', 'CEILING_LIGHT_DEVICE');
import { onesAdd } from "../../utils/common";
const ADD_ANCHOR = onesAdd("ceiling_light", deviceName);
describe('CeilingLight Connect - 添加吸顶灯设备', () => {
let driver: DeviceDriver;
@ -43,13 +45,13 @@ describe('CeilingLight Connect - 添加吸顶灯设备', () => {
await driver.destroySession();
});
it('[ONES:15950] 通过BLE添加吸顶灯', async () => {
it(`${ADD_ANCHOR} 通过BLE添加吸顶灯`, async () => {
const start = Date.now();
try {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record('[P0][ONES:15950] 添加吸顶灯设备', 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`${ADD_ANCHOR} 添加吸顶灯设备`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}
@ -65,10 +67,10 @@ describe('CeilingLight Connect - 添加吸顶灯设备', () => {
expect(result).toBe(true);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record('[P0][ONES:15950] 添加吸顶灯设备', 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
reporter.record(`${ADD_ANCHOR} 添加吸顶灯设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record('[P0][ONES:15950] 添加吸顶灯设备', 'FAIL', Date.now() - start, e.message, ss);
reporter.record(`${ADD_ANCHOR} 添加吸顶灯设备`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});

View File

@ -29,8 +29,8 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('ceilingLight', 'CEILING_LIGHT_DEVICE');
// 必测项控制锚点: 点击控制吸顶灯pro开/关灯 BLE 15975#2528azD6 / WiFi 15974#TcnL3gFM
const PROTO = process.env.PROTO === 'wifi' ? 'wifi' : 'ble';
const CTRL_CEIL = PROTO === 'wifi' ? '[P0][ONES:15974#TcnL3gFM][wifi]' : '[P0][ONES:15975#2528azD6][ble]';
import { onesCtrl } from "../../utils/common";
const CTRL_CEIL = onesCtrl("ceiling_light", deviceName);
describe('CeilingLight Control - 吸顶灯功能页', () => {
let driver: DeviceDriver;

View File

@ -49,7 +49,7 @@ describe('ColorBulb Connect - 添加彩灯设备', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record('[P0][ONES:15960] 添加彩灯设备', 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record('[P0][ONES:15960] 添加彩灯设备', 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}

View File

@ -4,22 +4,32 @@ import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter';
import {
sleep,
addDeviceViaBLE,
isDeviceOnHomepage,
addDeviceWithSerialPairing,
findDeviceNameOnHomepage,
saveAddedDevice,
onesAdd,
curtain3PostConnect,
curtainPostConnect,
} from '../../utils/common';
import { getDeviceName } from '../../config/device.config';
import { getDeviceName, getModelName } from '../../config/device.config';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('curtain', 'CURTAIN_DEVICE');
const model = getModelName('curtain', 'CURTAIN_DEVICE'); // 型号名(锚点用)
// curtain 与 curtain3 校准流程不同:按型号区分 relay 设备 + 连接后流程
const isCurtain3 = /curtain\s*3|curtain3/i.test(model);
const RELAY_DEVICE = isCurtain3 ? 'curtain3' : 'curtain';
const ADD_ANCHOR = onesAdd('curtain', model);
// 首页实际设备名匹配(真机名如 "Curtain 89" / "Curtain3 2B" 带 2 位十六进制后缀);
// 要求十六进制后缀,避免误匹配校准页提示文字(如 "...SwitchBot Curtain to the desired...")。可用 env 覆盖
const CURTAIN_PATTERN = process.env.CURTAIN_PATTERN || 'Curtain\\w*\\s+[0-9A-Fa-f]{2}\\b';
// 是否做行程校验(需设备装在真实导轨上);默认跳过(Install and calibrate later),CURTAIN_CALIBRATE=1 开启
const CALIBRATE = process.env.CURTAIN_CALIBRATE === '1';
// 设备维度动态添加锚点:按当前 CURTAIN_DEVICE 解析(Curtain/Curtain3/BlindTilt 各自的 ONES 号)
import { onesAdd } from '../../utils/common';
const ADD_ANCHOR = onesAdd('curtain', deviceName);
describe('Curtain Connect - 通过BLE添加窗帘设备', () => {
describe('Curtain Connect - 添加窗帘设备(串口配对 + 行程校验)', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
@ -34,30 +44,31 @@ describe('Curtain Connect - 通过BLE添加窗帘设备', () => {
await driver.destroySession();
});
it(`${ADD_ANCHOR} 通过BLE添加窗帘设备`, async () => {
it(`${ADD_ANCHOR} 通过BLE添加窗帘设备`, { timeout: 360000 }, async () => {
const start = Date.now();
try {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
const existing = await findDeviceNameOnHomepage(driver, CURTAIN_PATTERN);
if (existing) {
console.log(`${existing}已在首页,跳过重新添加`);
saveAddedDevice('curtain', existing);
reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`);
return;
}
const result = await addDeviceViaBLE(driver, {
categoryName: 'Curtain',
const result = await addDeviceWithSerialPairing(driver, {
categoryName: 'Curtain', // curtain / curtain3 同一添加入口
deviceKeyword: deviceName,
scanTimeout: 30000,
connectionKeywords: [
'Initial Setup', 'Start Using', 'Done', 'added successfully',
'Got it', 'cloud service', 'Pick a room', 'Calibrate',
'Open & Close', 'Set Direction', 'Next',
],
relayDevice: RELAY_DEVICE, // 按开关键 2s(curtain ch39 / curtain3 ch37)
registryCategory: 'curtain',
namePattern: CURTAIN_PATTERN,
// 连接后专属流程:选模式 + (curtain3 的行程校验 / curtain 稍后校准)
postConnectSteps: (d) =>
isCurtain3 ? curtain3PostConnect(d, { calibrate: CALIBRATE }) : curtainPostConnect(d, { calibrate: CALIBRATE }),
});
expect(result).toBe(true);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'PASS', Date.now() - start, `${model}添加成功${CALIBRATE ? '(含行程校验)' : ''}, 耗时${elapsed}s`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'FAIL', Date.now() - start, e.message, ss);

View File

@ -31,6 +31,8 @@ import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('fan', 'FAN_DEVICE');
import { onesAdd } from "../../utils/common";
const ADD_ANCHOR = onesAdd("fan", deviceName);
describe('Fan Connect - 添加风扇设备', () => {
let driver: DeviceDriver;
@ -53,13 +55,13 @@ describe('Fan Connect - 添加风扇设备', () => {
await driver.destroySession();
});
it('[ONES:122775] 通过BLE添加风扇', async () => {
it(`${ADD_ANCHOR} 通过BLE添加风扇`, async () => {
const start = Date.now();
try {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record('[P0][ONES:122775] 通过BLE添加风扇', 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`${ADD_ANCHOR} 通过BLE添加风扇`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}
@ -75,10 +77,10 @@ describe('Fan Connect - 添加风扇设备', () => {
expect(result).toBe(true);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record('[P0][ONES:122775] 通过BLE添加风扇', 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
reporter.record(`${ADD_ANCHOR} 通过BLE添加风扇`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record('[P0][ONES:122775] 通过BLE添加风扇', 'FAIL', Date.now() - start, e.message, ss);
reporter.record(`${ADD_ANCHOR} 通过BLE添加风扇`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});

View File

@ -33,8 +33,8 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('fan', 'FAN_DEVICE');
// 必测项控制锚点: 无电池款 Circulator Fan 首页控制开关 BLE 15975#JBszgxRS / WiFi 15974#KNyzKhn6
const PROTO = process.env.PROTO === 'wifi' ? 'wifi' : 'ble';
const CTRL_FAN = PROTO === 'wifi' ? '[P0][ONES:15974#KNyzKhn6][wifi]' : '[P0][ONES:15975#JBszgxRS][ble]';
import { onesCtrl } from "../../utils/common";
const CTRL_FAN = onesCtrl("fan", deviceName);
describe('Fan Control - 风扇控制页', () => {
let driver: DeviceDriver;

View File

@ -3,21 +3,27 @@ import { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter';
import {
sleep,
addDeviceViaBLE,
isDeviceOnHomepage,
addDeviceWithSerialPairing,
findDeviceNameOnHomepage,
saveAddedDevice,
configureHubWifi,
onesAdd,
} from '../../utils/common';
import { getDeviceName } from '../../config/device.config';
import { getDeviceName, getModelName } from '../../config/device.config';
import { getWifiCredentials } from '../../config/wifi.config';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('hub', 'HUB_DEVICE');
import { onesAdd } from "../../utils/common";
const ADD_ANCHOR = onesAdd("hub", deviceName);
const ADD_ANCHOR = onesAdd('hub', getModelName('hub', 'HUB_DEVICE'));
// App 添加目录里的具体型号入口(Hub 是分类标题,下挂 Hub Mini / Hub 2 / …);可用 env 覆盖
const HUB_CATEGORY = process.env.HUB_CATEGORY || 'Hub Mini';
// 首页实际设备名匹配(真机名,如 "Hub Mini XX");可用 env 覆盖
const HUB_PATTERN = process.env.HUB_PATTERN || 'Hub ?Mini\\s+\\w+';
describe('Hub Connect - 添加Hub设备', () => {
describe('Hub Connect - 添加Hub Mini设备(WiFi配网)', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
@ -32,32 +38,39 @@ describe('Hub Connect - 添加Hub设备', () => {
await driver.destroySession();
});
it(`${ADD_ANCHOR} 通过BLE添加Hub设备`, async () => {
it(`${ADD_ANCHOR} 通过BLE+WiFi添加Hub Mini`, { timeout: 240000 }, async () => {
const start = Date.now();
const wifi = getWifiCredentials();
try {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record(`${ADD_ANCHOR} 添加Hub设备`, 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
const existing = await findDeviceNameOnHomepage(driver, HUB_PATTERN);
if (existing) {
console.log(`${existing}已在首页,跳过重新添加`);
saveAddedDevice('hub', existing);
reporter.record(`${ADD_ANCHOR} 添加Hub Mini设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`);
return;
}
const result = await addDeviceViaBLE(driver, {
categoryName: 'Hub',
const result = await addDeviceWithSerialPairing(driver, {
categoryName: HUB_CATEGORY,
categoryScrollHint: 1, // Hub Mini 在添加目录第 1 屏可见
deviceKeyword: deviceName,
scanTimeout: 30000,
relayDevice: 'hub', // 继电器 ch40 长按 4s(config/relay.config)
registryCategory: 'hub',
namePattern: HUB_PATTERN,
// 连上设备后进入 Configure Wi-Fi 页 → 填 SSID/密码(woanmarket/woankeji,env 可覆盖)→ Next → 走完向导
postConnectSteps: (d) => configureHubWifi(d, wifi),
connectionKeywords: [
'Initial Setup', 'Start Using', 'Done', 'added successfully',
'Got it', 'cloud service', 'Pick a room', 'Display Type',
'Configure Wi-Fi', 'Your device is connected', 'Connect Device',
'Connecting', 'Initial Setup', 'Pick a room', 'Done', 'Next',
],
});
expect(result).toBe(true);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record(`${ADD_ANCHOR} 添加Hub设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
reporter.record(`${ADD_ANCHOR} 添加Hub Mini设备`, 'PASS', Date.now() - start, `${deviceName}添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record(`${ADD_ANCHOR} 添加Hub设备`, 'FAIL', Date.now() - start, e.message, ss);
reporter.record(`${ADD_ANCHOR} 添加Hub Mini设备`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});

View File

@ -38,7 +38,7 @@ describe('Humidifier Connect - 添加加湿器设备', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record(`${ADD_ANCHOR} 添加加湿器设备`, 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`${ADD_ANCHOR} 添加加湿器设备`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}

View File

@ -31,6 +31,8 @@ import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('keypad', 'KEYPAD_DEVICE');
import { onesAdd } from "../../utils/common";
const ADD_ANCHOR = onesAdd("keypad", deviceName);
describe('Keypad Connect - 添加Keypad设备', () => {
let driver: DeviceDriver;
@ -53,13 +55,13 @@ describe('Keypad Connect - 添加Keypad设备', () => {
await driver.destroySession();
});
it('[ONES:15949] 通过BLE添加Keypad', async () => {
it(`${ADD_ANCHOR} 通过BLE添加Keypad`, async () => {
const start = Date.now();
try {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record('[P0][ONES:15949] 通过BLE添加Keypad', 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`${ADD_ANCHOR} 通过BLE添加Keypad`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}
@ -75,10 +77,10 @@ describe('Keypad Connect - 添加Keypad设备', () => {
expect(result).toBe(true);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record('[P0][ONES:15949] 通过BLE添加Keypad', 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
reporter.record(`${ADD_ANCHOR} 通过BLE添加Keypad`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record('[P0][ONES:15949] 通过BLE添加Keypad', 'FAIL', Date.now() - start, e.message, ss);
reporter.record(`${ADD_ANCHOR} 通过BLE添加Keypad`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});

View File

@ -38,7 +38,7 @@ describe('Lock Connect - 添加Lock设备', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record(`${ADD_ANCHOR} 添加Lock设备`, 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`${ADD_ANCHOR} 添加Lock设备`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}

View File

@ -4,18 +4,22 @@ import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter';
import {
sleep,
addDeviceViaBLE,
isDeviceOnHomepage,
addDeviceWithSerialPairing,
findDeviceNameOnHomepage,
saveAddedDevice,
} from '../../utils/common';
import { getDeviceName } from '../../config/device.config';
import { getDeviceName, getModelName } from '../../config/device.config';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('meter', 'METER_DEVICE');
// 首页实际设备名的匹配模式(真机名如 "Meter 0B");要求十六进制后缀,且排除 "Outdoor Meter XX"(户外计前缀不同)。可用 env 覆盖
const METER_PATTERN = process.env.METER_PATTERN || '(?<!Outdoor )Meter\\s+[0-9A-Fa-f]{2}';
import { onesAdd } from "../../utils/common";
const ADD_ANCHOR = onesAdd("meter", deviceName);
// 锚点按型号名解析(不用注册表的实际名,否则锚点表查不到)
const ADD_ANCHOR = onesAdd("meter", getModelName('meter', 'METER_DEVICE'));
describe('Meter Connect - 添加Meter设备', () => {
let driver: DeviceDriver;
@ -32,20 +36,25 @@ describe('Meter Connect - 添加Meter设备', () => {
await driver.destroySession();
});
it(`${ADD_ANCHOR} 通过BLE添加Meter设备`, async () => {
it(`${ADD_ANCHOR} 通过BLE添加Meter设备`, { timeout: 150000 }, async () => {
const start = Date.now();
try {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record(`${ADD_ANCHOR} 添加Meter设备`, 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
// 已添加则按模式拿到首页实际名并回写;否则走串口配对添加
const existing = await findDeviceNameOnHomepage(driver, METER_PATTERN);
if (existing) {
console.log(`${existing}已在首页,跳过重新添加`);
saveAddedDevice('meter', existing); // 记录实际名作为功能页入口
reporter.record(`${ADD_ANCHOR} 添加Meter设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`);
return;
}
const result = await addDeviceViaBLE(driver, {
const result = await addDeviceWithSerialPairing(driver, {
categoryName: 'Meter',
categoryScrollHint: 4, // Meter 在添加目录约第 4 次下滑可见(提速,见 selectDeviceCategory)
deviceKeyword: deviceName,
scanTimeout: 30000,
relayDevice: 'meter', // 继电器按 meter 主键(config/relay.config)
registryCategory: 'meter', // 成功后回写注册表(进入功能页入口)
namePattern: METER_PATTERN, // 首页实际名匹配(如 "Meter 0B")
connectionKeywords: [
'Initial Setup', 'Start Using', 'Done', 'added successfully',
'Got it', 'cloud service', 'Pick a room', 'Display Type',

View File

@ -0,0 +1,75 @@
import { describe, it, beforeAll, afterAll, expect } from 'vitest';
import { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter';
import {
sleep,
addDeviceWithSerialPairing,
findDeviceNameOnHomepage,
saveAddedDevice,
onesAdd,
} from '../../utils/common';
import { getModelName } from '../../config/device.config';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// outdoor meter 是套件:选 "Outdoor Meter" → 过免责 → 选择页先点 "Add Meter" → 按 ch36 → 连接。
// 首页实际名 "Outdoor Meter XX"(与 indoor "Meter XX" 前缀不同,要求十六进制后缀避免误匹配)。
const OUTDOOR_PATTERN = process.env.OUTDOOR_METER_PATTERN || 'Outdoor Meter\\s+[0-9A-Fa-f]{2}';
const ADD_ANCHOR = onesAdd('meter', getModelName('outdoor_meter')); // 暂无独立锚点映射,留接口
describe('Outdoor Meter Connect - 添加户外温湿度计(套件)', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
beforeAll(async () => {
driver = createDriver();
await driver.createSession();
reporter = new TestReporter('OutdoorMeter_Connect', driver.platform.toUpperCase());
});
afterAll(async () => {
reporter.generate();
await driver.destroySession();
});
it(`${ADD_ANCHOR} 通过BLE添加Outdoor Meter设备`, { timeout: 150000 }, async () => {
const start = Date.now();
try {
const existing = await findDeviceNameOnHomepage(driver, OUTDOOR_PATTERN);
if (existing) {
console.log(`${existing}已在首页,跳过重新添加`);
saveAddedDevice('outdoor_meter', existing);
reporter.record(`${ADD_ANCHOR} 添加Outdoor Meter设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`);
return;
}
const result = await addDeviceWithSerialPairing(driver, {
categoryName: 'Outdoor Meter',
deviceKeyword: 'Outdoor Meter',
relayDevice: 'outdoor_meter', // 继电器按主键 2s(ch36)
registryCategory: 'outdoor_meter',
namePattern: OUTDOOR_PATTERN,
// 套件:过免责后先点 "Add Meter" 进计量器子流程,再按继电器
beforePairing: async (d) => {
const el = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Add Meter")');
if (el) { await d.tapElement(el); console.log('点 Add Meter 进计量器子流程'); }
},
connectionKeywords: [
'Initial Setup', 'Start Using', 'Done', 'added successfully', 'Added successfully',
'Got it', 'Pick a room', 'Select Room', 'Display Type',
],
});
expect(result).toBe(true);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record(`${ADD_ANCHOR} 添加Outdoor Meter设备`, 'PASS', Date.now() - start, `Outdoor Meter添加成功, 耗时${elapsed}s`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record(`${ADD_ANCHOR} 添加Outdoor Meter设备`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});
});

View File

@ -46,7 +46,7 @@ describe('OSC Connect - 通过BLE添加OSC', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record('[P0][ONES:113942] 通过BLE添加OSC', 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record('[P0][ONES:113942] 通过BLE添加OSC', 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}

View File

@ -38,7 +38,7 @@ describe('Plug Connect - 添加Plug设备', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record(`${ADD_ANCHOR} 添加Plug设备`, 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`${ADD_ANCHOR} 添加Plug设备`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}

View File

@ -0,0 +1,77 @@
import { describe, it, beforeAll, afterAll, expect } from 'vitest';
import { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter';
import {
sleep,
addDeviceWithSerialPairing,
findDeviceNameOnHomepage,
saveAddedDevice,
onesAdd,
} from '../../utils/common';
import { getDeviceName, getModelName } from '../../config/device.config';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('remote', 'REMOTE_DEVICE');
// 锚点按型号名解析(不用注册表实际名)
const ADD_ANCHOR = onesAdd('remote', getModelName('remote', 'REMOTE_DEVICE'));
// App 内添加品类名(Android 调试时若不符,改这一处即可)
const REMOTE_CATEGORY = process.env.REMOTE_CATEGORY || 'Remote';
// 首页实际设备名匹配模式(真机名与配置默认名可能不同);可用 env 覆盖
const REMOTE_PATTERN = process.env.REMOTE_PATTERN || 'Remote \\w+';
describe('Remote Connect - 添加Remote设备', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
beforeAll(async () => {
driver = createDriver();
await driver.createSession();
reporter = new TestReporter('Remote_Connect', driver.platform.toUpperCase());
});
afterAll(async () => {
reporter.generate();
await driver.destroySession();
});
it(`${ADD_ANCHOR} 通过BLE添加Remote设备`, { timeout: 150000 }, async () => {
const start = Date.now();
try {
const existing = await findDeviceNameOnHomepage(driver, REMOTE_PATTERN);
if (existing) {
console.log(`${existing}已在首页,跳过重新添加`);
saveAddedDevice('remote', existing);
reporter.record(`${ADD_ANCHOR} 添加Remote设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`);
return;
}
const result = await addDeviceWithSerialPairing(driver, {
categoryName: REMOTE_CATEGORY,
categoryScrollHint: 8, // Remote 在添加目录约第 8 次下滑可见(提速)
deviceKeyword: deviceName,
relayDevice: 'remote', // 继电器同时按凸键+凹键(config/relay.config)
registryCategory: 'remote',
namePattern: REMOTE_PATTERN,
repressOnRetry: true, // 凸+凹是切换式键:首按给足检测;若起始恰在"已配对"态,允许补按一次切回
pairRetries: 2, // 最多 2 轮
pairPollIters: 22, // 每轮检测窗口 ~33s:App 对 remote 进配对的 BLE 检测慢(实测可达16s+),窗口须大于它,否则超窗+补按反而失败
connectionKeywords: [
'Initial Setup', 'Start Using', 'Done', 'added successfully',
'Got it', 'cloud service', 'Pick a room', 'Next',
],
});
expect(result).toBe(true);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record(`${ADD_ANCHOR} 添加Remote设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record(`${ADD_ANCHOR} 添加Remote设备`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});
});

View File

@ -48,7 +48,7 @@ describe('Robot Connect - 添加扫地机', () => {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record(`${ADD_ANCHOR} 添加扫地机`, 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`${ADD_ANCHOR} 添加扫地机`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}

View File

@ -27,6 +27,8 @@ import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('stripLight', 'STRIP_LIGHT_DEVICE');
import { onesAdd } from "../../utils/common";
const ADD_ANCHOR = onesAdd("strip_light", deviceName);
describe('StripLight Connect - 添加灯带设备', () => {
let driver: DeviceDriver;
@ -43,13 +45,13 @@ describe('StripLight Connect - 添加灯带设备', () => {
await driver.destroySession();
});
it('[ONES:15959] 通过BLE添加灯带', async () => {
it(`${ADD_ANCHOR} 通过BLE添加灯带`, async () => {
const start = Date.now();
try {
const alreadyExists = await isDeviceOnHomepage(driver, deviceName);
if (alreadyExists) {
console.log(`${deviceName}已在首页,跳过重新添加`);
reporter.record('[P0][ONES:15959] 添加灯带设备', 'PASS', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
reporter.record(`${ADD_ANCHOR} 添加灯带设备`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`);
return;
}
@ -65,10 +67,10 @@ describe('StripLight Connect - 添加灯带设备', () => {
expect(result).toBe(true);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
reporter.record('[P0][ONES:15959] 添加灯带设备', 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
reporter.record(`${ADD_ANCHOR} 添加灯带设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`);
} catch (e: any) {
const ss = await driver.screenshot().catch(() => '');
reporter.record('[P0][ONES:15959] 添加灯带设备', 'FAIL', Date.now() - start, e.message, ss);
reporter.record(`${ADD_ANCHOR} 添加灯带设备`, 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});

View File

@ -29,8 +29,8 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const deviceName = getDeviceName('stripLight', 'STRIP_LIGHT_DEVICE');
// 必测项控制锚点: 点击控制Strip Light开/关 BLE 15975#4poyiVwD / WiFi 15974#QgX4dX7X
const PROTO = process.env.PROTO === 'wifi' ? 'wifi' : 'ble';
const CTRL_STRIP = PROTO === 'wifi' ? '[P0][ONES:15974#QgX4dX7X][wifi]' : '[P0][ONES:15975#4poyiVwD][ble]';
import { onesCtrl } from "../../utils/common";
const CTRL_STRIP = onesCtrl("strip_light", deviceName);
describe('StripLight Control - 灯带功能页', () => {
let driver: DeviceDriver;

View File

@ -0,0 +1,118 @@
/**
* Curtain / Curtain 3 ( + )
* **curtain curtain3 **,:
* - curtain3:已实测全程跑通(Custom Calibration:朝向Move left/right)
* - curtain(base): curtain3 ,;"稍后校准"
*
* addDeviceWithSerialPairing postConnectSteps /desc ,
*/
import { DeviceDriver } from '../../drivers/types';
import { sleep, waitForSource } from './element.helper';
export interface CurtainAddOptions {
/** true=做行程校验(需设备装在真实导轨上、电机能跑);false=Install and calibrate later 跳过校验。 */
calibrate?: boolean;
/** Determine 位置每个方向的行进等待(ms),默认 15000。 */
moveWaitMs?: number;
}
async function tapText(driver: DeviceDriver, txt: string): Promise<boolean> {
const el = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${txt}")`)
: await driver.findElementRaw('name', txt);
if (el) { await driver.tapElement(el); return true; }
return false;
}
async function tapDesc(driver: DeviceDriver, desc: string): Promise<boolean> {
const el = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', `new UiSelector().descriptionContains("${desc}")`)
: await driver.findElementRaw('predicate string', `label CONTAINS "${desc}" OR name CONTAINS "${desc}"`);
if (el) { await driver.tapElement(el); return true; }
return false;
}
/** 连接后第一步(curtain/curtain3 共用):Select Mode 选"单边开合" → Confirm。 */
async function selectModeOpenOneSide(driver: DeviceDriver): Promise<void> {
await waitForSource(driver, 'Select Mode', 60000).catch(() => {});
if (!(await tapDesc(driver, 'Open from one side'))) await tapText(driver, 'Open from one side');
await sleep(1000);
await tapText(driver, 'Confirm');
}
/** Curtain 3 连接后流程(含已验证的 Custom Calibration)。 */
export async function curtain3PostConnect(driver: DeviceDriver, opts: CurtainAddOptions = {}): Promise<void> {
const calibrate = opts.calibrate ?? false;
const moveWait = opts.moveWaitMs ?? 15000;
await selectModeOpenOneSide(driver);
await waitForSource(driver, 'Install', 30000).catch(() => {});
if (!calibrate) {
await tapText(driver, 'Install and calibrate later');
console.log('curtain3: 走 Install and calibrate later(跳过行程校验)');
return;
}
await tapText(driver, 'Install');
// Curtain Track 选轨道 → 底部 Skip
await waitForSource(driver, 'rail type', 15000).catch(() => {});
await tapText(driver, 'Skip');
// Calibrate → Custom Calibration
await waitForSource(driver, 'Custom Calibration', 15000).catch(() => {});
await tapText(driver, 'Custom Calibration');
// ① Confirm Orientation:勾选"logo 朝内"单选行 → Next
// 注意:用唯一子串 "logo is facing inwards"(单选行 "SwitchBot logo is facing inwards"),
// 不能用 "facing inwards" —— 会误匹配描述段 "...logo on your device is facing inwards",导致单选没勾上、Next 灰着。
await waitForSource(driver, 'Confirm Orientation', 15000).catch(() => {});
if (!(await tapDesc(driver, 'logo is facing inwards'))) await tapDesc(driver, 'facing inwards');
await sleep(800);
await tapText(driver, 'Next');
// ② Adjust Position:勾选"已关闭窗帘"单选行 → Start Calibration(同样用唯一子串避免误匹配描述)
await waitForSource(driver, 'fully closed', 15000).catch(() => {});
if (!(await tapDesc(driver, 'fully closed my curtains'))) await tapDesc(driver, 'fully closed');
await sleep(800);
await tapText(driver, 'Start Calibration');
// ③ Magnet Detection:Skip magnet detection → 弹框 Skip
await waitForSource(driver, 'Magnet Detection', 15000).catch(() => {});
await tapText(driver, 'Skip magnet detection');
await sleep(1500);
await tapText(driver, 'Skip');
// ④ Determine Fully open:Move left 行进 → 点"暂停"停在位 → Auto-Calibrate Close。
// Move left 点击后按钮会变成 "Pause"(窗帘行进中),必须先点 Pause 停下,否则位置没记录 → "Not calibrated"。
await waitForSource(driver, 'Move left', 20000).catch(() => {});
await tapText(driver, 'Move left');
await sleep(moveWait); // 让窗帘行进到目标全开位
// 行进中按钮变 Pause:点它停下(中英/可能为 "Pause"/"暂停")
if (!(await tapText(driver, 'Pause'))) await tapText(driver, '暂停');
await sleep(1500);
await tapText(driver, 'Auto-Calibrate Close');
await sleep(5000); // 等自动关闭校准结算(期间可能出现 "Something went wrong",仍可 Finish)
// ⑤ Finish:点一次可能不离开校验页(页面结算中),重试直到离开 Custom Calibration 页
for (let i = 0; i < 6; i++) {
const src = await driver.getSource();
if (!/Custom Calibration|Move left|Auto-Calibrate/i.test(src)) break; // 已离开校验页 = 完成
await tapText(driver, 'Finish');
await sleep(3000);
}
console.log('curtain3: Custom Calibration 完成');
}
/**
* Base Curtain curtain3 ****: Move left Move right
* ,;"稍后校准"
*/
export async function curtainPostConnect(driver: DeviceDriver, opts: CurtainAddOptions = {}): Promise<void> {
await selectModeOpenOneSide(driver);
await waitForSource(driver, 'Install', 30000).catch(() => {});
await tapText(driver, 'Install and calibrate later');
if (opts.calibrate) {
console.log('curtain(base): 行程校验流程(Move left→Move right)待该设备可连接后实测补全;当前已走"稍后校准"');
}
}

View File

@ -1,6 +1,8 @@
import { DeviceDriver } from '../../drivers/types';
import { sleep, waitForElement, waitForSource, scrollUntilFound } from './element.helper';
import { ensureHomeTab, navigateToAddPage, navigateThroughWizard } from './navigation.helper';
import { ensureHomeTab, navigateToAddPage, navigateThroughWizard, restartAndroidApp } from './navigation.helper';
import { enterPairingMode } from './relay.helper';
import { saveAddedDevice, clearAddedDevices } from './device-registry.helper';
export interface AddDeviceOptions {
categoryName: string;
@ -21,6 +23,12 @@ const DEFAULT_CONNECTION_KEYWORDS = [
const DEFAULT_WIZARD_BUTTONS = ['Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip', 'Next'];
// "连接已真正开始"的标志(用于判断配对是否触发);**不含引导页常驻的 Next/Got it**,避免误判已连。
const CONNECT_STARTED_KEYWORDS = [
'Connecting', 'connected', 'Connect Device', 'Discover device', 'Initial Setup',
'added successfully', 'Added successfully', 'Pick a room', 'Select Room', 'Select Mode',
];
export async function isDeviceOnHomepage(driver: DeviceDriver, keyword: string): Promise<boolean> {
await ensureHomeTab(driver);
let source = await driver.getSource();
@ -31,19 +39,59 @@ export async function isDeviceOnHomepage(driver: DeviceDriver, keyword: string):
return source.includes(keyword);
}
export async function selectDeviceCategory(driver: DeviceDriver, categoryName: string): Promise<boolean> {
/**
* ,****( "Meter 0B"), null
* "设备实际名与配置默认名不符"()
*/
export async function findDeviceNameOnHomepage(driver: DeviceDriver, pattern: string): Promise<string | null> {
// 先退出可能残留的添加向导/Attention 弹框(否则首页被遮挡)
for (const t of ['Return Home', 'Quit', 'Exit', 'Leave']) {
const el = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`)
: await driver.findElementRaw('name', t);
if (el) { await driver.tapElement(el); await sleep(2000); break; }
}
await driver.dismissPopupIfPresent();
await ensureHomeTab(driver);
const re = new RegExp(pattern);
for (let i = 0; i < 3; i++) {
const source = await driver.getSource();
const m = source.match(re);
if (m) return m[0];
await driver.scrollDown(400);
await sleep(500);
}
return null;
}
export async function selectDeviceCategory(driver: DeviceDriver, categoryName: string, scrollHint = 0): Promise<boolean> {
if (driver.platform === 'android') {
let el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${categoryName}")`);
if (!el) {
await driver.scrollDown(300);
await sleep(800);
el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${categoryName}")`);
const find = () => driver.findElementRaw('-android uiautomator', `new UiSelector().text("${categoryName}")`);
// 品类网格异步加载:短轮询等其渲染(idle=1s 下通常很快;Meter 等非热门品类首屏不可见,少等即可)
let el = await find();
for (let i = 0; i < 2 && !el; i++) { await sleep(400); el = await find(); }
// 首屏没有 → 手动下滑查找(不用慢的 UiScrollable.scrollIntoView,它每微滚都重查整棵树 ~12s)。
// 已知大概滑几次的品类(scrollHint):先快滑 hint-1 次不校验,再边滑边找——大幅省时。
if (!el && scrollHint > 1) {
for (let i = 0; i < scrollHint - 1; i++) await driver.scrollDown(900);
el = await find();
}
let swipes = 0;
for (let i = 0; i < 14 && !el; i++) {
await driver.scrollDown(900);
swipes++;
el = await find(); // idle=1s 下 find 很快,无需额外 sleep
}
if (!el) { console.log(`FAIL: no ${categoryName} option`); return false; }
if (scrollHint <= 1 && swipes) console.log(`[hint] ${categoryName} 实际滑动 ${(scrollHint || 0) + swipes} 次找到(可设为该品类 scrollHint 提速)`);
await driver.tapElement(el);
await sleep(3000);
await sleep(1200);
// 点击品类后通常进入 免责声明/引导/扫描 页:只要离开了品类网格即视为成功
const src = await driver.getSource();
return src.includes(categoryName) || src.includes('Select') || src.includes('Scanning') || src.includes('searching');
const leftGrid = !src.includes('Add Device') || src.includes('Disclaimers') || src.includes('Next') ||
src.includes('Scanning') || src.includes('searching') || src.includes(categoryName);
if (!leftGrid) { console.log(`FAIL: cannot select category ${categoryName}`); return false; }
return true;
}
// iOS - scroll to reveal category, check visibility, then click
@ -106,6 +154,7 @@ export async function waitForConnection(
for (const kw of connectionKeywords) {
if (source.includes(kw)) return true;
}
await driver.dismissPopupIfPresent(); // 连接/配网过程中途突然冒出的弹框,顺手关掉
await sleep(2000);
}
return false;
@ -190,6 +239,370 @@ export async function addDeviceViaBLE(driver: DeviceDriver, options: AddDeviceOp
return false;
}
export interface SerialPairingAddOptions {
categoryName: string; // 添加目录里的品类/产品名(如 'Meter' / 'Remote')
categoryScrollHint?: number; // 该品类在添加目录里大概滑动几次可见(提速:先快滑 hint-1 次再校验);日志会打印实测值
deviceKeyword: string; // 首页校验关键字(如 'Meter 3M')
relayDevice: string; // config/relay.config 的设备 key(如 'meter' / 'remote')
registryCategory?: string; // 成功后把设备名写入注册表的品类 key(如 'meter');默认用 relayDevice
namePattern?: string; // 首页实际设备名的正则(如 'Meter \\w+');匹配到则以实际名回写注册表
beforePairing?: (driver: DeviceDriver) => Promise<void>; // 过免责声明后、按继电器配对前的额外步骤(如 outdoor meter 套件先点 "Add Meter")
pairRetries?: number; // 配对未触发(按完没连上)时的最大尝试轮数,默认 3
pairPollIters?: number; // 每轮检测轮询次数(窗口=次数×1.5s),默认 12(~18s);remote 检测慢需调大
repressOnRetry?: boolean; // 重试时是否重按继电器;默认 true。切换式配对键(如 remote 凸+凹,按一次进/再按退)须设 false——只按一次,重试只重点 Next
postConnectSteps?: (driver: DeviceDriver) => Promise<void>; // 连接后专属流程(如 curtain 选模式+行程校验);提供则替代默认 navigateThroughWizard
holdMs?: number; // 覆盖按键时长
disclaimerMaxNext?: number; // 到"引导页(按按键处)"前需点几次 Next(过免责声明),默认 1
connectionKeywords?: string[];
wizardButtons?: string[];
}
/** 点"下一步"类按钮。Android 用 text("Next");iOS 配对页按钮叫 "Connect device"(非 Next),故按名依次尝试。返回是否点到。 */
async function tapNext(driver: DeviceDriver): Promise<boolean> {
if (driver.platform === 'android') {
const el = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")');
if (!el) return false;
await driver.tapElement(el);
return true;
}
// iOS:不同页的推进按钮名不同(免责声明="Next"、配对页="Connect device"、连接失败页="Retry"),依次尝试
for (const name of ['Next', 'Connect device', 'Connect Device', 'Retry', 'Continue', 'Done']) {
const el = await driver.findElementRaw('name', name).catch(() => null);
if (el) { await driver.tapElement(el); return true; }
}
return false;
}
/**
* "输入设备名"(/)
* (Android EditText / iOS TextField) = null
*/
export async function captureDeviceNameFromInput(driver: DeviceDriver): Promise<string | null> {
const el = driver.platform === 'android'
? await driver.findElementRaw('class name', 'android.widget.EditText')
: (await driver.findElementRaw('class name', 'XCUIElementTypeTextField'))
|| (await driver.findElementRaw('class name', 'XCUIElementTypeTextView'));
if (!el) return null;
let v: string | null = null;
for (const attr of ['text', 'value']) {
v = await driver.getElementAttribute(el, attr).catch(() => null);
if (v && String(v).trim()) break;
}
const name = v ? String(v).trim() : '';
// 过滤占位符/提示文案
if (!name || /enter|name|请输入|名称/i.test(name)) return null;
return name;
}
/**
* (meter / remote ):
* ( Disclaimers) **** Next
*
* 通用兼容:没有 Disclaimers ( Disclaimer )
* = ****(,);
* (config/device.config getDeviceName )
*/
export async function addDeviceWithSerialPairing(
driver: DeviceDriver,
options: SerialPairingAddOptions
): Promise<boolean> {
const {
categoryName,
categoryScrollHint = 0,
deviceKeyword,
relayDevice,
registryCategory = relayDevice,
namePattern,
beforePairing,
pairRetries = 3,
pairPollIters = 12,
repressOnRetry = true,
postConnectSteps,
holdMs,
disclaimerMaxNext = 1,
connectionKeywords,
wizardButtons = DEFAULT_WIZARD_BUTTONS,
} = options;
const addDevicePage = await navigateToAddPage(driver, 'Device');
if (!addDevicePage) { console.log('FAIL: cannot navigate to Add Device'); return false; }
const catSelected = await selectDeviceCategory(driver, categoryName, categoryScrollHint);
if (!catSelected) { console.log(`FAIL: cannot select category ${categoryName}`); return false; }
// 过免责声明到引导页(无 Disclaimers 的设备自动跳过)
for (let i = 0; i < disclaimerMaxNext; i++) {
const src = await driver.getSource();
if (!src.includes('Disclaimer')) break;
if (!(await tapNext(driver))) break;
await sleep(1500);
}
// 配对前额外步骤(如 outdoor meter 套件:先点 "Add Meter" 进计量器子流程)
if (beforePairing) { await beforePairing(driver); await sleep(2000); }
// 引导页:继电器按物理键进配对 → 点 Next 触发连接。
// 注意:有些设备(如 remote 凸+凹)的配对键是"切换式"——按一次进配对、再按一次退出。
// 这类设备传 repressOnRetry=false:只按一次,重试时仅重点 Next + 延长检测,绝不重按(重按会把它切出配对)。
const startedRe = namePattern ? new RegExp(namePattern) : null;
// 连接失败页标志(iOS "Failed to connect to your device" + Retry;curtain3 首连尤其易失败)
const FAIL_MARKERS = ['Failed to connect', 'Failed to Connect', 'Connection failed', 'Try Again', '连接失败', '无法连接'];
const pairingStarted = async (): Promise<boolean> => {
const src = await driver.getSource();
if (FAIL_MARKERS.some((k) => src.includes(k))) return false; // 失败页不算"已开始"(否则会跳过重试)
if (CONNECT_STARTED_KEYWORDS.some((k) => src.includes(k))) return true; // 进入连接/设置页
if (startedRe && startedRe.test(src)) return true; // 设备已被发现(如 "Remote 63")
return false;
};
let started = false;
let pressed = false;
for (let attempt = 0; attempt < pairRetries && !started; attempt++) {
// 第一次必按;之后是否重按取决于 repressOnRetry(切换式配对键不重按,只重点 Next)
if (!pressed || repressOnRetry) {
await enterPairingMode(relayDevice, holdMs ? { holdMs } : {}).catch((e) =>
console.log(`relay 配对按键失败(检查 64 路开发板接线/端口):${e.message}`)
);
pressed = true;
await sleep(800);
}
if (!(await tapNext(driver))) console.log('WARN: 引导页未找到 Next');
// 检测轮询:出现连接关键字/设备名即视为进配对成功。窗口 = pairPollIters×1.5s。
// remote 的 App BLE 检测延迟大且不稳(实测可达 16s+),需更长窗口(见 remote 用例 pairPollIters)。
for (let t = 0; t < pairPollIters && !started; t++) {
if (await pairingStarted()) { started = true; break; }
await driver.dismissPopupIfPresent(); // 配对/连接中途突然冒出的弹框,顺手关掉
await sleep(1500);
}
if (started) break;
console.log(`配对未触发/未发现设备(第 ${attempt + 1}/${pairRetries} 次)${repressOnRetry ? ',清弹框后重按继电器重试...' : ',仅重点 Next(切换式配对键不重按)...'}`);
await driver.dismissPopupIfPresent(); // 清可能的"连接失败"弹框,回到引导页再试
}
if (!started) console.log('WARN: 多次重试后仍未检测到连接开始,继续走后续(大概率失败)');
// 收尾等待(连上则多等让其完成);连接失败弹框主动关掉,避免残留挡住后续(尤其 curtain3)
await waitForConnection(driver, connectionKeywords, started ? 9000 : 3000);
await driver.dismissPopupIfPresent();
// 连接成功(或失败)后通常跳到"输入设备名"页:字段里的默认名即设备名 → 最可靠的取名处
const capturedName = await captureDeviceNameFromInput(driver);
if (capturedName) console.log(`从"输入设备名"页捕获默认名: "${capturedName}"`);
// 连接后:有专属流程(如 curtain 选模式 + 行程校验)则走它,否则走默认向导
if (postConnectSteps) await postConnectSteps(driver);
else await navigateThroughWizard(driver, wizardButtons);
await driver.dismissPopupIfPresent();
// 成功判据:输入页捕获到名字 或 设备出现在首页 → 记录实际设备名作为功能页入口
await ensureHomeTab(driver);
await sleep(1200);
const candidates = [capturedName, deviceKeyword].filter(Boolean) as string[];
const re = namePattern ? new RegExp(namePattern) : null;
for (let i = 0; i < 6; i++) {
await driver.dismissPopupIfPresent(); // 添加完成后弹出的"更新/评分/新功能"等弹框,先关再校验
const source = await driver.getSource();
for (const c of candidates) {
if (source.includes(c)) { saveAddedDevice(registryCategory, c); return true; }
}
if (re) {
const m = source.match(re);
if (m) { saveAddedDevice(registryCategory, m[0]); return true; } // 实际名(如 Meter 0B)
}
await driver.scrollDown(400);
await sleep(600);
}
// 首页没匹配上,但已从输入页拿到名字 → 仍记录并视为成功(连接成功/失败都会到输入名页)
if (capturedName) { saveAddedDevice(registryCategory, capturedName); return true; }
return false;
}
// 首页设备卡容器 id(网格左/右列)。各设备类型 name 控件 id 不同(nameText / nameTextMeter…),
// 但卡片容器 leftCard/rightCard 一致 → 用它通用枚举/长按删除,与设备类型无关。
const HOME_CARD_IDS = [
'com.theswitchbot.switchbot:id/leftCard',
'com.theswitchbot.switchbot:id/rightCard',
];
/** 若处于多选模式(顶部 Finish),退出它,回到正常首页。 */
async function exitSelectMode(driver: DeviceDriver): Promise<void> {
const finish = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")');
if (finish) { await driver.tapElement(finish); await sleep(1500); }
}
/** 找首页第一张设备卡容器(滚动查找),返回 {el, name};没有返回 null。 */
async function firstDeviceCard(driver: DeviceDriver): Promise<{ el: string; name: string } | null> {
// 轻量恢复到干净首页(退残留向导 + 关弹框 + 回首页),不做冷重启(冷启动会让首页卡片晚渲染→漏删)
await exitSelectMode(driver);
for (const t of ['Return Home', 'Quit', 'Exit', 'Leave']) {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`);
if (el) { await driver.tapElement(el); await sleep(1500); break; }
}
await driver.dismissPopupIfPresent();
await ensureHomeTab(driver);
await sleep(1500); // 等首页渲染
// 充分轮询(~20s):设备卡渲染慢也能等到。优先卡片容器,兜底设备名控件(各型号 id 不同)
for (let s = 0; s < 12; s++) {
for (const id of HOME_CARD_IDS) {
const els = await driver.findElementsRaw('id', id);
if (els.length) {
const nameEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceIdMatches(".*:id/name.*")').catch(() => null);
const name = (nameEl && (await driver.getElementAttribute(nameEl, 'text').catch(() => ''))) || '(device)';
return { el: els[0], name };
}
}
const nameEls = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().resourceIdMatches(".*:id/name.*")').catch(() => []);
if (nameEls && nameEls.length) {
const name = (await driver.getElementAttribute(nameEls[0], 'text').catch(() => '')) || '(device)';
return { el: nameEls[0], name };
}
await driver.scrollDown(500);
await sleep(900);
}
return null;
}
/** 长按某设备卡 → 多选模式 → 底部 Delete → 确认(删除单个,与房间无关)。 */
/**
* 批量删除:长按首张卡 () Delete
* (),
*/
async function deleteVisibleBatch(driver: DeviceDriver): Promise<string[]> {
const card = await firstDeviceCard(driver);
if (!card) return [];
const r0 = await driver.getElementRect(card.el);
const cx0 = Math.round(r0.x + r0.width / 2), cy0 = Math.round(r0.y + r0.height / 2);
await driver.longPress(cx0, cy0, 1.0);
await sleep(1000); // 等多选模式出现
// 是否仍处于"可删除"的有效多选(底部有 Delete)。跨房间多选会让 Delete 消失。
const hasDelete = () => driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
const exitSelect = async () => {
const finish = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")');
if (finish) { await driver.tapElement(finish); await sleep(800); }
};
// 长按未进入有效多选(无 Delete)→ 退出,交给外层下一轮/恢复
if (!(await hasDelete())) { await exitSelect(); return []; }
// 逐张点选本屏其它卡:点完校验 Delete 是否还在。
// 跨房间卡会让 Delete 消失 → 立即再点一次取消该卡(恢复有效选择),跳过它;
// 这样一批只删"同房间"设备,其它房间留给 removeAllDevices 外层循环。
const picked = [card.name];
const others: { cx: number; cy: number }[] = [];
for (const id of HOME_CARD_IDS) {
for (const el of await driver.findElementsRaw('id', id)) {
try {
const r = await driver.getElementRect(el);
const cx = Math.round(r.x + r.width / 2), cy = Math.round(r.y + r.height / 2);
if (Math.abs(cx - cx0) < 12 && Math.abs(cy - cy0) < 12) continue; // 跳过长按那张
others.push({ cx, cy });
} catch { /* 卡片可能已变,忽略 */ }
}
}
for (const { cx, cy } of others) {
await driver.tap(cx, cy);
await sleep(400);
if (await hasDelete()) {
picked.push('(device)'); // 同房间,保留选中
} else {
await driver.tap(cx, cy); // 跨房间→Delete消失,取消该卡恢复有效选择
await sleep(400);
}
}
// 点底部 Delete
const delBtn = await hasDelete();
if (!delBtn) { await exitSelect(); return []; }
await driver.tapElement(delBtn);
await sleep(900);
// 确认弹框(快速):Delete / OK / 删除 / Confirm
for (let retry = 0; retry < 3; retry++) {
const src = await driver.getSource();
if (/Cancel|取消|delete|删除/i.test(src)) {
let confirm: string | null = null;
for (const t of ['Delete', 'OK', '删除', 'Confirm']) {
confirm = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`);
if (confirm) break;
}
if (confirm) { await driver.tapElement(confirm); break; }
}
await sleep(500);
}
await sleep(1800); // 等删除生效 + 首页刷新
return picked;
}
/**
* reset "是否真的清空了"
* (leftCard/rightCard),Android ;iOS false
*/
export async function homepageHasDeviceCards(driver: DeviceDriver): Promise<boolean> {
if (driver.platform !== 'android') return false;
await ensureHomeTab(driver);
for (const id of HOME_CARD_IDS) {
const els = await driver.findElementsRaw('id', id);
if (els.length) return true;
}
return false;
}
/**
* **** , connect
* (nameText),(,),
*
*/
export async function removeAllDevices(
driver: DeviceDriver,
opts: { max?: number } = {}
): Promise<string[]> {
const removed: string[] = [];
if (driver.platform === 'ios') {
// iOS:逐个删(最稳)。按设备名找卡 → 长按进多选 → 底部 Delete → 弹框 "Confirm"。循环到无设备。
const NAME_RE = /(Meter|Outdoor Meter|Remote|Curtain|Hub Mini|Hub 2|Hub|Bot|Plug|Lock|Keypad|Blind Tilt)\s+[0-9A-Za-z]{2}\b/;
const maxIos = opts.max ?? 50;
for (let round = 0; round < maxIos; round++) {
const fin = await driver.findElementRaw('name', 'Finish').catch(() => null);
if (fin) { await driver.tapElement(fin); await sleep(800); } // 退出残留多选
const src = await driver.getSource();
const m = src.match(NAME_RE);
if (!m) break; // 首页已无设备
const name = m[0];
const card = await driver.findElementRaw('predicate string', `name == "${name}" OR label == "${name}"`).catch(() => null);
if (!card) break;
const r = await driver.getElementRect(card);
await driver.longPress(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2), 1.2);
await sleep(1500);
const del = await driver.findElementRaw('name', 'Delete').catch(() => null);
if (!del) { const f2 = await driver.findElementRaw('name', 'Finish').catch(() => null); if (f2) await driver.tapElement(f2); break; }
await driver.tapElement(del); await sleep(1200);
const confirm = await driver.findElementRaw('name', 'Confirm').catch(() => null); // 确认框 "Delete the selected items?" → Confirm
if (confirm) await driver.tapElement(confirm);
await sleep(2500);
removed.push(name);
console.log(`已删 ${name}`);
}
clearAddedDevices();
console.log(`removeAllDevices[ios] 完成,共删除 ${removed.length} 个: ${removed.join(', ') || '(无)'}`);
return removed;
}
if (driver.platform !== 'android') {
console.log('removeAllDevices: 该平台未实现');
return removed;
}
const max = opts.max ?? 50;
// 不在开头冷重启(会让首页卡片晚渲染→漏删);firstDeviceCard 内已做轻量恢复 + 充分轮询
for (let round = 0; round < max; round++) {
const batch = await deleteVisibleBatch(driver);
if (!batch.length) break; // 首页已无任何设备卡
removed.push(...batch);
console.log(`本批删除 ${batch.length} 个: ${batch.join(', ')}`);
}
clearAddedDevices();
console.log(`removeAllDevices 完成,共删除 ${removed.length} 个: ${removed.join(', ') || '(无)'}`);
return removed;
}
export async function removeDeviceFromAccount(driver: DeviceDriver, keyword: string): Promise<boolean> {
await ensureHomeTab(driver);

View File

@ -0,0 +1,53 @@
/**
* "首页实际设备名",/
*
* ****:test-plan/added-devices.<platform>.json(android / ios )
* - Android + iOS ,;
* - ,
* process.env.PLATFORM( config/app.config , ios)
*
* config/device.config.ts getDeviceName:env > >
* ,
*/
import * as fs from 'fs';
import * as path from 'path';
function currentPlatform(): string {
return (process.env.PLATFORM || 'ios').toLowerCase();
}
function registryFile(platform = currentPlatform()): string {
return path.resolve(__dirname, '../../test-plan', `added-devices.${platform}.json`);
}
function readRegistry(platform = currentPlatform()): Record<string, string> {
try {
const f = registryFile(platform);
if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, 'utf-8'));
} catch { /* 忽略损坏文件 */ }
return {};
}
/** 写入某品类添加后的实际设备名(进入功能页的入口),写到当前平台的注册表。 */
export function saveAddedDevice(category: string, deviceName: string): void {
const platform = currentPlatform();
const f = registryFile(platform);
const reg = readRegistry(platform);
reg[category] = deviceName;
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, JSON.stringify(reg, null, 2), 'utf-8');
console.log(`已记录设备入口[${platform}]: ${category} → "${deviceName}" (${f})`);
}
/** 读当前平台某品类已添加的设备名;无则返回 undefined。 */
export function getAddedDevice(category: string): string | undefined {
return readRegistry()[category];
}
/** 清空当前平台的注册表(必测重跑前清设备后调用,避免残留旧入口名)。 */
export function clearAddedDevices(): void {
const platform = currentPlatform();
const f = registryFile(platform);
if (fs.existsSync(f)) fs.writeFileSync(f, '{}\n', 'utf-8');
console.log(`已清空设备注册表[${platform}] (${f})`);
}

View File

@ -1,5 +1,6 @@
import { DeviceDriver } from '../../drivers/types';
import { sleep, waitForSource } from './element.helper';
import { extractTicketId, setSharedTicketId } from './zendesk.helper';
export async function navigateToFeedback(driver: DeviceDriver): Promise<boolean> {
if (driver.platform === 'android') {
@ -60,3 +61,25 @@ export async function submitFeedback(
await sleep(2000);
return true;
}
/**
* feedback ,(RN插件 / )
* ; null reports/.zendesk-ticket.json
* ,"产出", resolveTicketId() "取用"
*/
export async function captureFeedbackTicketId(driver: DeviceDriver): Promise<string | null> {
// 提交成功页可能需要一两秒渲染工单号
let ticketId: string | null = null;
for (let i = 0; i < 3 && !ticketId; i++) {
const source = await driver.getSource();
ticketId = extractTicketId(source);
if (!ticketId) await sleep(1500);
}
if (ticketId) {
setSharedTicketId(ticketId);
console.log(`feedback 工单号已捕获并保存: ${ticketId}`);
} else {
console.log('feedback 提交页未解析到工单号(检查 extractTicketId 规则与实际 UI)');
}
return ticketId;
}

View File

@ -0,0 +1,93 @@
/**
* Hub Mini WiFi "Configure Wi-Fi" :
* - SSID 字段:默认预填手机当前 WiFi( "Deco"), SSID
* - 密码字段:hint "Enter Wi-Fi password",
* - Next ,
* 2.4GHz WiFi() config/wifi.config(env , woanmarket/woankeji)
*/
import { DeviceDriver } from '../../drivers/types';
import { sleep } from './element.helper';
import { getWifiCredentials, WifiCredentials } from '../../config/wifi.config';
import { navigateThroughWizard } from './navigation.helper';
const EDIT = 'new UiSelector().className("android.widget.EditText")';
/** 在 "Configure Wi-Fi" 页填 SSID + 密码并 Next;随后走完向导。creds 缺省取 config/wifi.config。 */
export async function configureHubWifi(driver: DeviceDriver, creds?: WifiCredentials): Promise<void> {
const { ssid, password } = creds || getWifiCredentials();
// 等 "Configure Wi-Fi" 页出现(文本判定,跨平台)。BLE 连接较慢(relay 后约 24s 连上、27s 出页),窗口要够长(~37s)。
let onWifiPage = false;
for (let i = 0; i < 25; i++) {
const src = await driver.getSource();
if (/Configure Wi-?Fi|Wi-?Fi password|配置 ?Wi-?Fi/i.test(src)) { onWifiPage = true; break; }
await driver.dismissPopupIfPresent();
await sleep(1500);
}
if (!onWifiPage) { console.log('WARN: 未到达 Configure Wi-Fi 页,跳过配网'); return; }
if (driver.platform === 'android') {
// Android:SSID/密码都是 EditText,直接定位元素 clear + type
const eds = await driver.findElementsRaw('-android uiautomator', EDIT);
if (eds.length < 2) { console.log('WARN: 未找到 SSID/密码输入框,跳过配网'); return; }
let ssidEl = eds[0], pwdEl = eds[1];
try { const t0 = (await driver.getElementAttribute(eds[0], 'text')) || ''; if (/password|密码/i.test(t0)) { pwdEl = eds[0]; ssidEl = eds[1]; } } catch { /* 默认顺序 */ }
await driver.clearText(ssidEl).catch(() => {});
await driver.typeText(ssidEl, ssid); await sleep(500);
await driver.typeText(pwdEl, password); await sleep(500);
try { await driver.goBack(); } catch { /* 收键盘 */ }
await sleep(500);
} else {
// iOS:SSID/密码框是自绘控件、无障碍不可见 → 按坐标点 + 模拟键盘键入(/wda/keys)。
// 坐标按 iPhone 14 Pro(393×852pt):SSID≈(118,358)、密码≈(118,443);换机型需调。
// 重要:SSID 预填手机当前 WiFi(如 Deco),聚焦**不会**自动全选,必须先清空——双击选中该词再键入替换;
// 最后必须用键盘 "Return" 收起键盘,否则 Next 被键盘挡住点不到。
const d = driver as any;
await d.tap(118, 358); await sleep(800); // 聚焦 SSID
await d.doubleTap(118, 358); await sleep(700); // 双击选中预填值(单词)
await d.sendKeys(['\b']); await sleep(300); // 删除选中(退格)
await d.sendKeys([ssid]); await sleep(800); // 键入目标 SSID
await d.tap(118, 443); await sleep(800); // 聚焦密码(通常为空)
await d.sendKeys([password]); await sleep(800);
const ret = await driver.findElementRaw('name', 'Return').catch(() => null);
if (ret) { await driver.tapElement(ret); await sleep(1000); } // 收起键盘
}
console.log(`Hub WiFi 配网:SSID="${ssid}" 密码已填,提交...`);
// 点 Next(iOS 名为 Next;Android text=Next)
const next = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")')
: await driver.findElementRaw('name', 'Next');
if (next) await driver.tapElement(next);
// 提交后是多步配网过程:连接 WiFi → 验证成功 → 设备配网成功 → 跳添加成功页,过程较久。
// 期间都是进度页,**不要乱点按钮**(误点会打断配网),只被动等"成功"强标志出现 + 关弹框。
const SUCCESS = [
'added successfully', 'Added successfully', 'Start Using', 'Use now', 'Pick a room',
'Set up complete', 'Setup complete', 'configured successfully', 'connected to',
'添加成功', '配网成功', '连接成功', '设置完成',
];
const FAIL = ['Failed', 'failed', 'incorrect', 'wrong', 'Unable', '失败', '密码错误', 'try again', 'Try Again'];
let reached = false;
for (let t = 0; t < 30; t++) { // 最多 ~90s 等配网过程
const src = await driver.getSource();
if (SUCCESS.some((k) => src.includes(k))) { console.log(`Hub 配网成功(到结果页, +${t * 3}s)`); reached = true; break; }
if (FAIL.some((k) => src.includes(k))) {
const vis = (src.match(/text="([^"]+)"/g) || []).map((x) => x.replace(/text="|"/g, '')).filter(Boolean).slice(0, 12).join(' | ');
console.log(`WARN: Hub 配网疑似失败(+${t * 3}s)。页面:${vis}`);
break;
}
if (t % 3 === 0) { // 每 ~9s 记一次当前阶段,便于排查"连接/验证/配网"卡在哪
const vis = (src.match(/text="([^"]+)"/g) || []).map((x) => x.replace(/text="|"/g, '')).filter(Boolean).slice(0, 8).join(' | ');
console.log(` 配网进行中 +${t * 3}s: ${vis}`);
}
await driver.dismissPopupIfPresent();
await sleep(3000);
}
if (!reached) console.log('WARN: 未等到 Hub 配网成功页(可能 WiFi 不可达/非2.4G/密码错,或过程超 90s)');
// 到结果页后走完结尾向导(Start Using / Done / 房间等)
await driver.dismissPopupIfPresent();
await navigateThroughWizard(driver, ['Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip', 'Next'], 8);
}

View File

@ -10,3 +10,10 @@ export * from './scene.helper';
export * from './feedback.helper';
export * from './network.helper';
export * from './ones-anchor.helper';
export * from './zendesk.helper';
export * from './serial_controller';
export * from './relay.helper';
export * from './device-registry.helper';
export * from './qr.helper';
export * from './curtain.helper';
export * from './hub.helper';

View File

@ -1,13 +1,37 @@
import { DeviceDriver } from '../../drivers/types';
import { sleep, waitForElement, waitForSource } from './element.helper';
import { execSync } from 'child_process';
import { APP_CONFIG } from '../../config/app.config';
/**
* Android App(force-stop + ) //,"+"
* soak 实测:curtain3 "+",
*/
export async function restartAndroidApp(driver: DeviceDriver): Promise<void> {
const pkg = APP_CONFIG.android.appPackage;
try {
execSync(`adb shell am force-stop ${pkg}`, { stdio: 'ignore' });
await sleep(1500);
await driver.activateApp(pkg).catch(() => {
try { execSync(`adb shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, { stdio: 'ignore' }); } catch { /* ignore */ }
});
await sleep(6000);
await driver.dismissPopupIfPresent();
await ensureHomeTab(driver);
await driver.dismissPopupIfPresent();
} catch (e: any) {
console.log('restartAndroidApp 异常:', e.message);
}
}
export async function ensureHomeTab(driver: DeviceDriver): Promise<void> {
await driver.goBackToHomepage();
await sleep(1000);
await sleep(600);
if (driver.platform === 'android') {
try {
const homeTab = await driver.findElementRaw('accessibility id', 'Home');
if (homeTab) { await driver.tapElement(homeTab); await sleep(1000); }
if (homeTab) { await driver.tapElement(homeTab); await sleep(600); }
} catch { /* element may be stale after navigation */ }
} else {
const homeTab = await driver.findElementRaw('predicate string', 'name BEGINSWITH "HomeScene"');
@ -19,25 +43,43 @@ export async function navigateToAddPage(
driver: DeviceDriver,
type: 'Device' | 'Scene'
): Promise<boolean> {
await driver.dismissPopupIfPresent();
await ensureHomeTab(driver);
await driver.dismissPopupIfPresent();
if (driver.platform === 'android') {
const addBtn = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/addBto');
if (!addBtn) { console.log('FAIL: addBto not found'); return false; }
await driver.tapElement(addBtn);
await sleep(1500);
const addBtoId = 'com.theswitchbot.switchbot:id/addBto';
const menuText = type === 'Device' ? 'Add Device' : 'Add Scene';
const menuEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${menuText}")`);
if (!menuEl) { console.log(`FAIL: no ${menuText} in menu`); return false; }
await driver.tapElement(menuEl);
await sleep(3000);
const src = await driver.getSource();
if (type === 'Device') return src.includes('Bot') || src.includes('Add Device');
return src.includes('Create Scene');
// 最多 3 次。快路:直接找"+";在则我们已在可加设备的首页,跳过 dismiss/ensureHomeTab 恢复(省 ~6s)。
// 找不到"+"才恢复(首轮轻量:dismiss+ensureHomeTab;再不行才重启 App 清残留弹框)。
for (let attempt = 0; attempt < 3; attempt++) {
let addBtn = await driver.findElementRaw('id', addBtoId);
if (!addBtn) {
await driver.dismissPopupIfPresent();
await ensureHomeTab(driver);
await driver.dismissPopupIfPresent();
if (attempt > 0) await restartAndroidApp(driver); // 轻量恢复无效 → 重启清残留弹框
addBtn = await driver.findElementRaw('id', addBtoId);
}
if (addBtn) {
await driver.tapElement(addBtn);
// 轮询菜单出现(替代固定 sleep1000),出现即走
let menuEl: string | null = null;
for (let i = 0; i < 8 && !menuEl; i++) {
menuEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${menuText}")`);
if (!menuEl) await sleep(250);
}
if (menuEl) {
await driver.tapElement(menuEl);
// 轮询目标页加载(替代固定 sleep1500)
for (let i = 0; i < 8; i++) {
const src = await driver.getSource();
const ok = type === 'Device' ? (src.includes('Bot') || src.includes('Add Device')) : src.includes('Create Scene');
if (ok) return true;
await sleep(300);
}
}
}
console.log(`navigateToAddPage 第${attempt + 1}次未成(addBto/菜单缺失或残留弹框)...`);
}
console.log('FAIL: cannot navigate to Add Device(重试后仍失败)');
return false;
}
// iOS
@ -102,31 +144,24 @@ export async function navigateToManageScenes(driver: DeviceDriver): Promise<bool
export async function navigateThroughWizard(
driver: DeviceDriver,
buttonNames = ['Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip', 'Next'],
maxSteps = 10
maxSteps = 6
): Promise<boolean> {
if (driver.platform === 'android') {
for (let i = 0; i < maxSteps; i++) {
await sleep(1000);
const src = await driver.getSource();
if (!src.includes('Add Bot') && !src.includes('Added successfully') && !src.includes('Install')) {
if (await driver.isOnHomepage()) return true;
}
let tapped = false;
for (const btn of buttonNames) {
try {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${btn}")`);
if (el) {
await driver.tapElement(el);
await sleep(2000);
tapped = true;
break;
}
} catch { /* stale element, continue */ }
}
if (!tapped) {
await driver.goBack();
await sleep(1000);
const src = await driver.getSource(); // 每轮只取一次整页,避免重复 getSource
// 到首页:有底部导航/首页列表,且无添加向导标记
const onWizard = /Add (Bot|Meter|Curtain|Remote|Outdoor|Lock|Plug|Hub|Keypad|Fan|Light|Sensor|Device)|Added successfully|Install|Initial Setup|Select Mode|Disclaimers|Calibrat/i.test(src);
const onHome = src.includes('homeRecyclerView') || src.includes('addBto') ||
src.includes('content-desc="Home"') || src.includes('Automations') || src.includes('Add a Device');
if (!onWizard && onHome) return true;
// 从已取的 src 判断当前页存在哪个向导按钮,只查这一个(省去逐个 7 次查询)
const btn = buttonNames.find(b => src.includes(`text="${b}"`));
if (btn) {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${btn}")`);
if (el) { await driver.tapElement(el); await sleep(1200); continue; }
}
// 没有可点按钮:可能已完成或加载中,短等再判
await sleep(700);
}
return await driver.isOnHomepage();
}

View File

@ -23,15 +23,26 @@ const ANCHORS: Record<string, Record<string, DeviceAnchor>> = {
lock: {
'Lock 6F': { add: 15970, ctrlBle: '5YEfmpJz', ctrlWifi: '7cEkbh4Y' },
'LockPro 7G': { add: 66694, ctrlBle: 'LnPNEJmS', ctrlWifi: 'Virku9zd' },
// 以下变体 UI 相似:仅添加(控制必测无独立 step;Lock Ultra 为全新 UI,归新写)
'Lock Lite': { add: 304132 },
'Lock & Kit': { add: 51440 },
'Lock Pro & Kit': { add: 105436 },
'Lock Pro WiFi': { add: 304133 },
},
plug: {
// base Plug 仅 WiFi 必测控制(BLE 无)
'Plug 4D': { add: 15966, ctrlWifi: 'Ay79Pj23' },
'PlugMini 5E': { add: 15958, ctrlBle: '2jSFfY4k', ctrlWifi: '3nXsFW9n' },
'eu Plug': { add: 332918, ctrlBle: 'TSVKWcJ3', ctrlWifi: 'U5z9wocr' },
'Garage Door Opener': { add: 302289, ctrlBle: 'E5YRm4Pg', ctrlWifi: '2nRaC87F' },
// Relay Switch 有 1pm/1/2pm开关/2pm覆盖 多个物理型号,共用添加用例 302288;此处取 1PM 为代表
'Relay Switch 1PM': { add: 302288, ctrlBle: 'MKgLq1oy', ctrlWifi: 'XvFF8AK7' },
},
sensor: {
'Contact Sensor 5O': { add: 15962, ctrlBle: 'A8C4BsCm', ctrlWifi: 'JKSgHHbC' },
'Motion Sensor 6P': { add: 15963, ctrlBle: 'HrigrD2X', ctrlWifi: 'N7fSHmH3' },
// IOSensor 控制必测是历史数据报警/校正(现有 sensor_control 未覆盖)→ 仅添加
'IOSensor': { add: 35871 },
},
humidifier: {
'Humidifier 1K': { add: 15964, ctrlBle: 'GkkrZpaC', ctrlWifi: '8tx3Rg76' },
@ -43,18 +54,51 @@ const ANCHORS: Record<string, Record<string, DeviceAnchor>> = {
'Hub2 8H': { add: 21787 },
'HubMini 9I': { add: 15972 },
'HubMiniMatter 0J': { add: 65336 },
'Hub2 S3': { add: 319304 },
'Hub Mini S3': { add: 319305 },
'Hub Mini Matter S3': { add: 319303 },
'Hub Plus': { add: 15971 },
},
meter: {
// meter 控制必测(报警/校正)现有 control 未覆盖;仅锚添加。Outdoor Meter 暂无明确 ONES 对应,未映射
'Meter 3M': { add: 15965 },
// meter 控制必测(报警/校正)现有 control 未覆盖;仅锚添加。Climate Panel/Radiator 为全新 UI,归新写
'Meter 3M': { add: 15965 },
'Meter Plus': { add: 15956 },
'Meter Pro': { add: 267367 },
'Meter Pro CO2': { add: 267366 },
},
robot: {
// robot 控制必测(清扫/暂停/回充)为 WiFi,现有无 _control;仅锚添加
// robot 控制必测(清扫/暂停/回充)为 WiFi,现有无 _control;仅锚添加。K11+/S20 为全新 UI,归新写
'Robot S1': { add: 15952 },
'Robot S1P': { add: 15951 },
'Robot K10+': { add: 40954 },
'Robot K10+ Pro': { add: 122737 },
'Robot K10+ Pro Combo': { add: 162261 },
'Robot S10': { add: 78369 },
},
keypad: {
// keypad 无控制必测;keypad touch UI 相似仅添加。Keypad Vision 为全新 UI,归新写
'Keypad 2V': { add: 15949 },
'Keypad Touch': { add: 15948 },
},
strip_light: {
'StripLight 9S': { add: 15959, ctrlBle: '4poyiVwD', ctrlWifi: 'QgX4dX7X' },
'RGBWW Strip Light3': { add: 301585, ctrlBle: '3ob75cjf', ctrlWifi: 'N1kzcb7S' },
'RGBICWW Strip Light': { add: 311018, ctrlBle: 'QAHNrXrm', ctrlWifi: 'MAwYyCaC' },
'RGBIC Neon Wire Rope': { add: 311020, ctrlBle: 'E2QCS8xy', ctrlWifi: '5sJcsTgi' },
'RGBIC Neon Rope': { add: 393235, ctrlWifi: 'BiEXxZDr' },
},
ceiling_light: {
'CeilingLight 7Q': { add: 15950, ctrlBle: '2528azD6', ctrlWifi: 'TcnL3gFM' }, // pro
'CeilingLight lte': { add: 15950, ctrlBle: 'V15M64cP', ctrlWifi: 'WL8KSQLb' }, // lte 与 pro 共用添加用例
'RGBWW Floor Lamp': { add: 301586, ctrlBle: 'JVnRz7xY', ctrlWifi: '2hm6sXrk' },
'RGBICWW Floor Lamp': { add: 311019, ctrlBle: 'AfxmoAEq', ctrlWifi: 'ML8CSkWX' },
'Candle Lamp': { add: 393236, ctrlWifi: 'RXm3iNLR' },
},
fan: {
'Fan 0T': { add: 122775, ctrlBle: 'JBszgxRS', ctrlWifi: 'KNyzKhn6' }, // 无电池款
'Battery Circulator Fan': { add: 48159, ctrlBle: 'CiFPEbNS', ctrlWifi: '6RC22BGo' },
'Standing Circulator Fan': { add: 393234, ctrlBle: 'XWmNA9UL' },
},
};
const curProto = (): 'ble' | 'wifi' => (process.env.PROTO === 'wifi' ? 'wifi' : 'ble');

85
utils/common/qr.helper.ts Normal file
View File

@ -0,0 +1,85 @@
/**
* helper , WiFi QR ****
* (,; helper + QR )
*
* 依赖:pngjs( PNG)+ jsqr() PNG
*/
import { PNG } from 'pngjs';
import jsQR from 'jsqr';
import { DeviceDriver } from '../../drivers/types';
/** 从 PNG buffer 解码二维码;失败返回 null。可选裁剪中心区域提高小码命中率。 */
export function decodeQRFromPngBuffer(buf: Buffer, cropCenter = false): string | null {
const png = PNG.sync.read(buf);
let data = png.data, width = png.width, height = png.height;
if (cropCenter) {
// 取中心 60% 区域(二维码通常居中)重采样,提高识别率
const cw = Math.floor(width * 0.6), ch = Math.floor(height * 0.6);
const ox = Math.floor((width - cw) / 2), oy = Math.floor((height - ch) / 2);
const cropped = Buffer.alloc(cw * ch * 4);
for (let y = 0; y < ch; y++) {
png.data.copy(cropped, y * cw * 4, ((oy + y) * width + ox) * 4, ((oy + y) * width + ox + cw) * 4);
}
data = cropped; width = cw; height = ch;
}
const code = jsQR(new Uint8ClampedArray(data), width, height);
return code ? code.data : null;
}
/** 从 base64 PNG 解码。先整图,失败再试中心裁剪。 */
export function decodeQRFromBase64(b64: string): string | null {
const buf = Buffer.from(b64, 'base64');
return decodeQRFromPngBuffer(buf, false) || decodeQRFromPngBuffer(buf, true);
}
/** 截当前屏并解码(Appium 截图)。 */
export async function decodeQRFromScreen(driver: DeviceDriver): Promise<string | null> {
const b64 = await driver.screenshot();
return decodeQRFromBase64(b64);
}
/**
* QR WiFi
* SwitchBot JSON:{"s":"<ssid>","p":"<password>","t":"<token>","r":"<region>"}
* WiFi `WIFI:S:<ssid>;T:WPA;P:<pass>;;`, key=value
*/
export interface WifiQR {
ssid?: string;
password?: string;
token?: string;
region?: string;
raw: string;
}
export function parseWifiQR(text: string): WifiQR {
const out: WifiQR = { raw: text };
// 1) SwitchBot JSON 码
try {
const j = JSON.parse(text);
if (j && typeof j === 'object') {
out.ssid = j.s ?? j.ssid;
out.password = j.p ?? j.password ?? j.pwd;
out.token = j.t ?? j.token;
out.region = j.r ?? j.region;
if (out.ssid || out.password) return out;
}
} catch { /* 非 JSON,继续 */ }
// 2) 标准 WiFi 码
const std = text.match(/WIFI:(?:[^;]*;)*S:([^;]*);(?:[^;]*;)*P:([^;]*);/i);
if (std) { out.ssid = std[1]; out.password = std[2]; return out; }
// 3) 启发式
const ssid = text.match(/(?:ssid|wifi_?name)["':=\s]+([^"',;&}\s]+)/i);
const pwd = text.match(/(?:password|pass|pwd|psk)["':=\s]+([^"',;&}\s]+)/i);
if (ssid) out.ssid = ssid[1];
if (pwd) out.password = pwd[1];
return out;
}
/** 校验 QR 内 WiFi 凭据是否与期望一致(用于摄像头配网断言)。 */
export function verifyWifiQR(text: string, expect: { ssid?: string; password?: string }): { ok: boolean; parsed: WifiQR; reason?: string } {
const parsed = parseWifiQR(text);
if (expect.ssid && parsed.ssid !== expect.ssid) return { ok: false, parsed, reason: `SSID 不符: 期望 ${expect.ssid} 实际 ${parsed.ssid}` };
if (expect.password && parsed.password !== expect.password) return { ok: false, parsed, reason: `密码不符: 期望 ${expect.password} 实际 ${parsed.password}` };
return { ok: true, parsed };
}

View File

@ -0,0 +1,82 @@
/**
* helper ,/,
*
* ():
* await enterPairingMode('remote'); // 按 config 默认(凸+凹 3s)
* await enterPairingMode('meter', { holdMs: 2500 }); // 临时覆盖时长
* await pressButton('remote', 'convex', 800); // 单键点按
*
* / env(SERIAL_PORT/SERIAL_BAUD), opts
* ();, opts.controller
*/
import { SerialController, SerialOptions } from './serial_controller';
import { resolveRelayAction, resolveRelayButton } from '../../config/relay.config';
export interface RelayActionOptions extends SerialOptions {
/** 覆盖配置里的按住时长(毫秒)。 */
holdMs?: number;
/** 复用已打开的控制器(不则自建并在结束时关闭)。 */
controller?: SerialController;
}
/** 用一个已开/自建的控制器执行 fn,自建的用完即关。 */
async function withController<T>(
opts: RelayActionOptions,
fn: (c: SerialController) => Promise<T>
): Promise<T> {
if (opts.controller) return fn(opts.controller);
const c = new SerialController(opts);
await c.open();
try {
return await fn(c);
} finally {
await c.close();
}
}
/**
* ( 'pairing' ): config +,
* "同时按下",
*/
export async function triggerRelayAction(
device: string,
action = 'pairing',
opts: RelayActionOptions = {}
): Promise<{ channels: number[]; holdMs: number; desc?: string }> {
const resolved = resolveRelayAction(device, action);
const holdMs = opts.holdMs ?? resolved.holdMs;
await withController(opts, (c) => c.pulseChannels(resolved.channels, holdMs));
console.log(`relay: ${device}.${action} → 通道[${resolved.channels.join(',')}] 按住 ${holdMs}ms${resolved.desc ? ' (' + resolved.desc + ')' : ''}`);
return { ...resolved, holdMs };
}
/** 让设备进入添加/配对模式(= triggerRelayAction(device,'pairing'))。 */
export function enterPairingMode(device: string, opts: RelayActionOptions = {}) {
return triggerRelayAction(device, 'pairing', opts);
}
/** 点按设备某个命名按键 ms 毫秒(单键)。 */
export async function pressButton(
device: string,
button: string,
ms = 500,
opts: RelayActionOptions = {}
): Promise<number> {
const ch = resolveRelayButton(device, button);
await withController(opts, (c) => c.pulse(ch, ms));
console.log(`relay: 点按 ${device}.${button} → 通道 ${ch} ${ms}ms`);
return ch;
}
/** 同时按住一组命名按键 ms 毫秒后释放(任意组合)。 */
export async function pressButtonsTogether(
device: string,
buttons: string[],
ms = 500,
opts: RelayActionOptions = {}
): Promise<number[]> {
const channels = buttons.map((b) => resolveRelayButton(device, b));
await withController(opts, (c) => c.pulseChannels(channels, ms));
console.log(`relay: 同时按 ${device}.[${buttons.join('+')}] → 通道[${channels.join(',')}] ${ms}ms`);
return channels;
}

View File

@ -0,0 +1,194 @@
/**
* 64 KMCZE-I64O64-V2.5(RS232/RS485, 16 )
*
* 用途:设备,(/)
*
* (): / 1 / ; RS232 115200RS485 9600
* env,绝不写死:SERIAL_PORT / SERIAL_BAUD
*
* (16 ):
* 控制单路: 55 C8 <ch> <01|00> 55 (ch=1..64 ;01 / 00 )
* 64: 55 C8 41 <8 字节状态> 55
* 读状态: 55 C7 01 00 55 1B DB <8字节输入><8字节输出> 0C
* 查版本: 55 D3 D3 00 55 AB 2D AA 20 ...(,)
* 延时通断: 55 C9 <ch> <01断|02通> <2字节×0.1s> 55
*/
import { SerialPort } from 'serialport';
import * as fs from 'fs';
import * as path from 'path';
const DEFAULT_PORT = '/dev/cu.usbserial-A50285BI';
// 按钮点击寿命统计:按「板子(串口序列号)→通道」累加,持久化。继电器寿命约 20 万次。
const PRESS_COUNT_FILE = path.resolve(__dirname, '../../test-plan/relay-press-counts.json');
const RELAY_LIFESPAN = 200000;
export interface SerialOptions {
port?: string;
baudRate?: number;
/** 发送后等待应答的毫秒数(默认 300ms) */
responseTimeout?: number;
}
export class SerialController {
private sp: SerialPort | null = null;
private readonly path: string;
private readonly baudRate: number;
private readonly responseTimeout: number;
constructor(opts: SerialOptions = {}) {
this.path = opts.port || process.env.SERIAL_PORT || DEFAULT_PORT;
this.baudRate = opts.baudRate || parseInt(process.env.SERIAL_BAUD || '115200', 10);
this.responseTimeout = opts.responseTimeout ?? 300;
}
get portPath(): string {
return this.path;
}
/** 板子标识:从串口路径取序列号(如 usbserial-A50285BI → A50285BI),区分多块板;env SERIAL_BOARD_ID 可覆盖。 */
get boardId(): string {
if (process.env.SERIAL_BOARD_ID) return process.env.SERIAL_BOARD_ID;
const m = this.path.match(/usbserial[-_]?([A-Za-z0-9]+)/i);
return m ? m[1] : this.path.replace(/^.*\//, '');
}
/** 累加该板各通道的按钮点击次数(继电器寿命约 20 万次),持久化到 test-plan/relay-press-counts.json。 */
private countPress(channels: number[]): void {
try {
let data: Record<string, Record<string, number>> = {};
if (fs.existsSync(PRESS_COUNT_FILE)) data = JSON.parse(fs.readFileSync(PRESS_COUNT_FILE, 'utf-8'));
const board = this.boardId;
data[board] = data[board] || {};
for (const ch of channels) data[board][ch] = (data[board][ch] || 0) + 1;
fs.mkdirSync(path.dirname(PRESS_COUNT_FILE), { recursive: true });
fs.writeFileSync(PRESS_COUNT_FILE, JSON.stringify(data, null, 2), 'utf-8');
} catch { /* 计数失败不影响主流程 */ }
}
open(): Promise<void> {
return new Promise((resolve, reject) => {
this.sp = new SerialPort(
{ path: this.path, baudRate: this.baudRate, parity: 'none', stopBits: 1, dataBits: 8 },
(err) => (err ? reject(err) : resolve())
);
});
}
async close(): Promise<void> {
if (this.sp?.isOpen) await new Promise<void>((r) => this.sp!.close(() => r()));
this.sp = null;
}
/** 发送一帧并收集 responseTimeout 窗口内的所有回包,返回拼接后的 Buffer。 */
send(frame: Buffer | number[]): Promise<Buffer> {
const buf = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
return new Promise((resolve, reject) => {
if (!this.sp?.isOpen) return reject(new Error('串口未打开,请先 open()'));
const chunks: Buffer[] = [];
const onData = (d: Buffer) => chunks.push(d);
this.sp.on('data', onData);
this.sp.write(buf, (err) => {
if (err) {
this.sp?.off('data', onData);
return reject(err);
}
});
setTimeout(() => {
this.sp?.off('data', onData);
resolve(Buffer.concat(chunks));
}, this.responseTimeout);
});
}
private assertChannel(ch: number): void {
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
}
/** 第 ch 路接通(ch: 1..64) */
relayOn(ch: number): Promise<Buffer> {
this.assertChannel(ch);
this.countPress([ch]);
return this.send([0x55, 0xc8, ch, 0x01, 0x55]);
}
/** 第 ch 路断开(ch: 1..64) */
relayOff(ch: number): Promise<Buffer> {
this.assertChannel(ch);
return this.send([0x55, 0xc8, ch, 0x00, 0x55]);
}
/** 脉冲:接通 ch 路 ms 毫秒后断开 —— 用于模拟"按一下物理按键"。 */
async pulse(ch: number, ms = 500): Promise<void> {
await this.relayOn(ch);
await new Promise((r) => setTimeout(r, ms));
await this.relayOff(ch);
}
/**
* 64 (8 )
* 规格:高位在左;=ch57-64,=ch1-8
* 已实测:ch334 0x01ch340x02ch350x04(ch34+35=0x06)
*/
static buildMask(channels: number[]): Buffer {
const mask = Buffer.alloc(8);
for (const ch of channels) {
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
const group = Math.floor((ch - 1) / 8); // 0=ch1-8 ... 7=ch57-64
const byteIdx = 7 - group; // 高位在左
const bit = (ch - 1) % 8;
mask[byteIdx] |= 1 << bit;
}
return mask;
}
/**
* 64 路状态:列表内通道接通( 55 C8 41 )
* "同时按下"(,) []
*/
setChannels(channels: number[]): Promise<Buffer> {
if (channels.length) this.countPress(channels); // 仅"通"计入点击;allOff([]) 不计
const mask = SerialController.buildMask(channels);
return this.send([0x55, 0xc8, 0x41, ...mask, 0x55]);
}
/** 全部继电器断开。 */
allOff(): Promise<Buffer> {
return this.setChannels([]);
}
/**
* 多键脉冲:同时接通 channels ms "同时按住多个物理键"
* ( remote 进配对:同时按凸键+)
*/
async pulseChannels(channels: number[], ms = 500): Promise<void> {
await this.setChannels(channels);
await new Promise((r) => setTimeout(r, ms));
await this.allOff();
}
/** 读 64 路输入(采集)+ 64 路继电器输出状态。返回原始回包(1B DB ... 0C)。 */
readStatus(): Promise<Buffer> {
return this.send([0x55, 0xc7, 0x01, 0x00, 0x55]);
}
/** 查询控制器版本(只读,不影响任何继电器)。返回应答里第 5..18 字节是版本字符串。 */
async queryVersion(): Promise<{ raw: string; version: string }> {
const resp = await this.send([0x55, 0xd3, 0xd3, 0x00, 0x55]);
const raw = resp.toString('hex').toUpperCase();
// 应答:AB 2D AA <版本ASCII...> BA;版本从第 5 字节(index 4)起
const ascii = resp.length > 5 ? resp.subarray(4, resp.length - 1).toString('ascii').replace(/[^\x20-\x7E]/g, '').trim() : '';
return { raw, version: ascii };
}
}
/** 便捷探测:打开 → 查版本 → 关闭。用于确认板子在线与波特率。 */
export async function probeBoard(opts: SerialOptions = {}): Promise<{ port: string; baudRate: number; alive: boolean; version: string; raw: string }> {
const c = new SerialController(opts);
await c.open();
try {
const { version, raw } = await c.queryVersion();
return { port: c.portPath, baudRate: opts.baudRate || parseInt(process.env.SERIAL_BAUD || '115200', 10), alive: raw.length > 0, version, raw };
} finally {
await c.close();
}
}

View File

@ -0,0 +1,136 @@
/**
* Zendesk helper RN插件
*
* 数据来源:App feedback ,Zendesk ****
* (App Version / JS_Version / Bundle_Version)Bundle_Version RN
* 注意:这些信息不在工单的结构化字段(custom_fields),
*
* ****,:
* ticketId > env ZENDESK_TICKET_ID > reports/.zendesk-ticket.json
* :`15945 feedback提交` setSharedTicketId()
* RN插件 resolveTicketId() getTicketRnPlugins()
*
* 鉴权:Zendesk API Basic `<email>/token:<api_token>`, .env
* ZENDESK_DOMAIN / ZENDESK_EMAIL / ZENDESK_TOKEN
*/
import * as fs from 'fs';
import * as path from 'path';
const SHARED_TICKET_FILE = path.resolve(__dirname, '../../reports/.zendesk-ticket.json');
export interface RnPluginInfo {
ticketId: string;
appVersion: string | null; // 如 "9.26(23)"
jsVersion: string | null; // 如 "1.0.0.2"
bundleRaw: string | null; // Bundle_Version 原始串
bundle: Record<string, string>; // 解析后的 模块 -> 版本,如 { Home: "default 1.0.49.0", Fan2: "1.0.44.0" }
}
function env(key: string): string {
const v = process.env[key];
if (!v) throw new Error(`缺少环境变量 ${key}(请在 .env 配置 Zendesk 连接)`);
return v;
}
/** 上游用例(如 feedback 提交)拿到工单号后写入,供「获取RN插件」用例消费。 */
export function setSharedTicketId(ticketId: string | number): void {
fs.mkdirSync(path.dirname(SHARED_TICKET_FILE), { recursive: true });
fs.writeFileSync(
SHARED_TICKET_FILE,
JSON.stringify({ ticketId: String(ticketId), ts: new Date().toISOString() }, null, 2),
'utf-8'
);
}
/**
* feedback UI (,便)
* (/Ticket/Feedback/Request + ),
*/
export function extractTicketId(source: string): string | null {
const labeled = [
/工\s*单\s*号\s*[:#]?\s*(\d{4,})/i,
/(?:ticket|feedback|request|case)\s*(?:id|no|number|#)?\s*[:#]?\s*(\d{4,})/i,
/#\s*(\d{6,})/,
];
for (const re of labeled) {
const m = source.match(re);
if (m) return m[1];
}
// 兜底:页面里第一个 6~10 位独立数字(多数工单号长度)
const fallback = source.match(/(?<!\d)(\d{6,10})(?!\d)/);
return fallback ? fallback[1] : null;
}
/** 解析工单号:入参 > env ZENDESK_TICKET_ID > 共享文件。都没有则抛错。 */
export function resolveTicketId(explicit?: string | number): string {
if (explicit != null && String(explicit).trim()) return String(explicit).trim();
if (process.env.ZENDESK_TICKET_ID) return process.env.ZENDESK_TICKET_ID.trim();
if (fs.existsSync(SHARED_TICKET_FILE)) {
const j = JSON.parse(fs.readFileSync(SHARED_TICKET_FILE, 'utf-8'));
if (j?.ticketId) return String(j.ticketId);
}
throw new Error('未提供工单号:请传入 ticketId、设置 ZENDESK_TICKET_ID,或由上游用例 setSharedTicketId() 写入');
}
/**
* Bundle_Version ->
* "Home:default 1.0.49.0Scene:default 1.0.46.0Fan2:1.0.44.0..."
* (, token, "default " )
*/
export function parseBundleVersion(raw: string): Record<string, string> {
const out: Record<string, string> = {};
// 模块名:大写字母开头的驼峰/带数字 token,后跟冒号;值取到下一个这样的 token 之前。
const re = /([A-Z][A-Za-z0-9]*)\s*:\s*(.*?)(?=[A-Z][A-Za-z0-9]*\s*:|$)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const name = m[1].trim();
const ver = m[2].trim();
if (name) out[name] = ver;
}
return out;
}
/** 从一段评论正文里抽取 App/JS/Bundle 版本(取第一处命中)。 */
export function parseDeviceInfo(text: string): Omit<RnPluginInfo, 'ticketId'> {
const grab = (re: RegExp): string | null => {
const m = text.match(re);
return m ? m[1].trim() : null;
};
const appVersion = grab(/App\s*Version\s*[:]\s*([^\n\r]+)/i);
const jsVersion = grab(/JS[_\s]*Version\s*[:]\s*([^\n\r]+)/i);
const bundleRaw = grab(/Bundle[_\s]*Version\s*[:]\s*([^\n\r]+)/i);
return {
appVersion,
jsVersion,
bundleRaw,
bundle: bundleRaw ? parseBundleVersion(bundleRaw) : {},
};
}
/** 拉取工单全部评论(按时间倒序返回正文数组,最新在前)。 */
export async function fetchTicketComments(ticketId: string): Promise<string[]> {
const domain = env('ZENDESK_DOMAIN').replace(/\/+$/, '');
const auth = Buffer.from(`${env('ZENDESK_EMAIL')}/token:${env('ZENDESK_TOKEN')}`).toString('base64');
const url = `${domain}/api/v2/tickets/${ticketId}/comments.json?sort_order=desc`;
const resp = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
if (!resp.ok) {
throw new Error(`Zendesk 取评论失败 ticket=${ticketId} HTTP ${resp.status} ${await resp.text()}`);
}
const data: any = await resp.json();
return (data.comments || []).map((c: any) => String(c.body || c.html_body || ''));
}
/**
* RN插件用例主入口:给定工单号(, resolveTicketId),
* app App/JS/Bundle (, Bundle_Version )
*/
export async function getTicketRnPlugins(ticketId?: string | number): Promise<RnPluginInfo> {
const id = resolveTicketId(ticketId);
const comments = await fetchTicketComments(id);
for (const body of comments) {
if (/Bundle[_\s]*Version/i.test(body)) {
return { ticketId: id, ...parseDeviceInfo(body) };
}
}
return { ticketId: id, appVersion: null, jsVersion: null, bundleRaw: null, bundle: {} };
}

View File

@ -181,6 +181,35 @@ export class TestReporter {
const products = Object.keys(productMap);
const moduleCount = Object.values(productMap).reduce((s, m) => s + Object.keys(m).length, 0);
// ---- 结论 / 存在的问题 / 解决方案 ----
const esc = (s: string) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const failedResults = results.filter(r => r.status === 'FAIL');
const failSuites = Array.from(new Set(failedResults.map(r => r.suite)));
const SOL_KB: { re: RegExp; sol: string }[] = [
{ re: /cannot navigate|addBto|添加页/i, sol: '导航到添加页失败(首页"+"被残留弹框/卡死页挡住):已加 navigateToAddPage 找不到"+"时重启 App 兜底 + dismissPopupIfPresent 识别连接失败弹框。' },
{ re: /connection timeout|连接超时|未连上|配对未触发/i, sol: '连接/配对未触发:检查蓝牙与设备在线;已加"配对未发现设备就重按继电器重试(pairRetries)"。' },
{ re: /Something went wrong|行程|calibrat|校验/i, sol: '行程校验异常:需设备装在真实导轨上;Auto-Calibrate Close 后重试 Finish 已加。' },
{ re: /select category|no .* option|品类/i, sol: '品类选择失败:已用 UiScrollable.scrollIntoView 直达深层品类 + 重启兜底。' },
{ re: /expected false to be true|not found|未在首页|未找到/i, sol: '设备未成功添加/首页未识别:核对配对触发(pairRetries)与设备名匹配(namePattern,需含十六进制后缀)。' },
];
const conclusionLines: string[] = [];
if (effective === 0) conclusionLines.push('本次无有效用例(全部跳过)。');
else if (failed === 0) conclusionLines.push(`全部 <b>${effective}</b> 条有效用例通过,通过率 <b style="color:#97cc64">${passRate}%</b>,功能稳定。`);
else conclusionLines.push(`通过率 <b>${passRate}%</b>(${passed}/${effective}),失败 <b style="color:#fd5a3e">${failed}</b> 条,涉及:${failSuites.map(esc).join('、')}`);
if (skipped > 0) conclusionLines.push(`另有 ${skipped} 条跳过(未计入通过率)。`);
const problemItems = failedResults.map(r =>
`<li><span class="badge failed">FAIL</span> <b>${esc(r.suite)}</b> / ${esc(r.name)}<div class="kv-detail">${esc((r.detail || '无详情').slice(0, 400))}</div>${r.screenshot ? `<div class="case-screenshot"><img src="data:image/png;base64,${r.screenshot}"/></div>` : ''}</li>`
).join('\n');
const solSet = new Set<string>();
for (const r of failedResults) {
const hit = SOL_KB.find(x => x.re.test(`${r.name} ${r.detail || ''}`));
solSet.add(hit ? hit.sol : '查看该用例的失败详情/截图定位原因。');
}
const conclusionHtml = `<ul class="kv-list">${conclusionLines.map(c => `<li>${c}</li>`).join('')}</ul>`;
const problemsHtml = failedResults.length ? `<ul class="kv-list">${problemItems}</ul>` : `<p class="kv-ok">无失败用例 ✓</p>`;
const solutionsHtml = solSet.size ? `<ul class="kv-list">${Array.from(solSet).map(s => `<li>${esc(s)}</li>`).join('')}</ul>` : `<p class="kv-ok">无需处理,继续监控。</p>`;
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
@ -269,6 +298,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.case-screenshot img{max-width:300px;border-radius:6px;border:1px solid #3d4550}
.footer{text-align:center;padding:24px;color:#555;font-size:11px}
.kv-box{background:#2d333b;border-radius:10px;padding:14px 18px;margin-bottom:18px;border:1px solid #373e47}
.kv-list{list-style:none;padding:0}
.kv-list li{padding:8px 0;border-bottom:1px solid #353c45;font-size:13px;line-height:1.6}
.kv-list li:last-child{border-bottom:none}
.kv-detail{color:#9aa;font-size:12px;margin-top:4px;white-space:pre-wrap;background:#1e2228;padding:6px 10px;border-radius:5px}
.kv-ok{color:#97cc64;font-size:13px}
</style>
</head>
<body>
@ -300,13 +335,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
<div class="section-hd">Products (${products.length}) &middot; ${moduleCount} Modules &middot; ${total} Cases</div>
${productSections}
<div class="section-hd"> (Conclusion)</div>
<div class="kv-box">${conclusionHtml}</div>
<div class="section-hd"> (Problems)</div>
<div class="kv-box">${problemsHtml}</div>
<div class="section-hd"> (Solutions)</div>
<div class="kv-box">${solutionsHtml}</div>
<div class="footer">Generated by AI_UIAutomation &middot; ${products.length} products &middot; ${moduleCount} modules &middot; ${total} cases</div>
</div>
</body>
</html>`;
fs.writeFileSync(reportFile, html);
fs.unlinkSync(SHARED_RESULTS_FILE);
if (!process.env.SOAK_MODE) fs.unlinkSync(SHARED_RESULTS_FILE); // soak 模式保留,累积全程结果
console.log('\n' + '='.repeat(60));
console.log(' 全量测试报告');

View File

@ -288,6 +288,11 @@ export class WDAHelper {
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/clear`);
}
/** 向当前聚焦的输入框发送键盘按键。自绘/RN 输入框对 element/value 无效,需模拟真实键入。 */
async sendKeys(keys: string[]): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/wda/keys`, { value: keys });
}
async findBotCard(): Promise<string | null> {
return this.findDeviceCard('Bot');
}