diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index ce776c0..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index cf8b038..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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)" - ] - } -} diff --git a/.gitignore b/.gitignore index c66a932..527af01 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ midscene_run/ .idea/ .DS_Store .claude/ + +# 旧 Appium/pytest 必测用例 + allure 报告产物(不入库) +test_must_case/ +# 测试计划/清单(本地生成,机器相关) +test-plan/ +# 文档(单独维护,不随代码提交) +docs/ diff --git a/config/device.config.ts b/config/device.config.ts index 06a2e74..e1e2b4b 100644 --- a/config/device.config.ts +++ b/config/device.config.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { }; 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]!; } diff --git a/config/relay.config.ts b/config/relay.config.ts new file mode 100644 index 0000000..6cc2a6b --- /dev/null +++ b/config/relay.config.ts @@ -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__(全大写)。 + */ + +/** 通道号:两端相同写数字;两端不同写 { 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; // 按键名 → 通道(可按平台) + actions?: Record; +} + +export const RELAY_MAP: Record = { + 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); +} diff --git a/config/wifi.config.ts b/config/wifi.config.ts new file mode 100644 index 0000000..2a7e0f6 --- /dev/null +++ b/config/wifi.config.ts @@ -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), + }; +} diff --git a/docs/UI自动化测试计划.docx b/docs/UI自动化测试计划.docx deleted file mode 100644 index 32aec98..0000000 Binary files a/docs/UI自动化测试计划.docx and /dev/null differ diff --git a/docs/generate_test_plan.py b/docs/generate_test_plan.py deleted file mode 100644 index f138f20..0000000 --- a/docs/generate_test_plan.py +++ /dev/null @@ -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条协议超级用例逐 step:BLE 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 条 na:safety 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 仅 WiFi;Bot/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 Friends(Bot复用+定制皮肤)、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×2(Samsung 等)+ iOS×2(iPhone)', '已有'), - ('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 agent(Claude 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('白天调 Android,iOS 夜间挂测——让 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 难以进一步压缩。' - '项目使用 Midscene(AI 元素定位)已减少传统选择器维护成本,故未再按“零调试”乐观值估算。' - '若需再压缩周期,杠杆在于:多台真机并行、收窄 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/EU)UI差异程度?是否需逐个调试?') -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}') diff --git a/drivers/android-driver.ts b/drivers/android-driver.ts index 904bdf2..b432201 100644 --- a/drivers/android-driver.ts +++ b/drivers/android-driver.ts @@ -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 { 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 { - 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 { - 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 { return await this.request('GET', `/session/${this.sessionId}/screenshot`); } @@ -301,22 +323,36 @@ export class AndroidDriver implements DeviceDriver { } async dismissPopupIfPresent(): Promise { - 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; } } diff --git a/drivers/wda-driver.ts b/drivers/wda-driver.ts index d10cecc..9048143 100644 --- a/drivers/wda-driver.ts +++ b/drivers/wda-driver.ts @@ -71,6 +71,11 @@ export class WDADriver implements DeviceDriver { return this.wda.typeText(elementId, text); } + /** 向聚焦输入框发送键盘按键(iOS 自绘输入框用)。 */ + async sendKeys(keys: string[]): Promise { + return this.wda.sendKeys(keys); + } + async clearText(elementId: string): Promise { return this.wda.clearText(elementId); } diff --git a/global-setup.ts b/global-setup.ts index 8fb6757..2302644 100644 --- a/global-setup.ts +++ b/global-setup.ts @@ -67,7 +67,7 @@ function checkAppium(): Promise { } 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()}`); } diff --git a/package-lock.json b/package-lock.json index bc5ef52..988c589 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2b41980..8b228d4 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/prompts/must_test_conversion.md b/prompts/must_test_conversion.md index 428c035..5d75cc7 100644 --- a/prompts/must_test_conversion.md +++ b/prompts/must_test_conversion.md @@ -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`。** diff --git a/scripts/debug-add-page.ts b/scripts/debug-add-page.ts new file mode 100644 index 0000000..6e48d55 --- /dev/null +++ b/scripts/debug-add-page.ts @@ -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); }); diff --git a/scripts/debug-add-shot.ts b/scripts/debug-add-shot.ts new file mode 100644 index 0000000..b51d193 --- /dev/null +++ b/scripts/debug-add-shot.ts @@ -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 页并逐页截图(过 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); }); diff --git a/scripts/debug-bot-scan.ts b/scripts/debug-bot-scan.ts new file mode 100644 index 0000000..3458c9e --- /dev/null +++ b/scripts/debug-bot-scan.ts @@ -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); }); diff --git a/scripts/debug-curtain-add.ts b/scripts/debug-curtain-add.ts new file mode 100644 index 0000000..19f7194 --- /dev/null +++ b/scripts/debug-curtain-add.ts @@ -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); }); diff --git a/scripts/debug-curtain3-cal.ts b/scripts/debug-curtain3-cal.ts new file mode 100644 index 0000000..484be8a --- /dev/null +++ b/scripts/debug-curtain3-cal.ts @@ -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); }); diff --git a/scripts/debug-find-category.ts b/scripts/debug-find-category.ts new file mode 100644 index 0000000..f217368 --- /dev/null +++ b/scripts/debug-find-category.ts @@ -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); }); diff --git a/scripts/debug-longpress-delete.ts b/scripts/debug-longpress-delete.ts new file mode 100644 index 0000000..eca2a98 --- /dev/null +++ b/scripts/debug-longpress-delete.ts @@ -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); }); diff --git a/scripts/dump-product-catalog.ts b/scripts/dump-product-catalog.ts new file mode 100644 index 0000000..5d976a3 --- /dev/null +++ b/scripts/dump-product-catalog.ts @@ -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(); + // 非产品的 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); }); diff --git a/scripts/gen-combined.ts b/scripts/gen-combined.ts new file mode 100644 index 0000000..d1c3e53 --- /dev/null +++ b/scripts/gen-combined.ts @@ -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 || '(无结果)'); diff --git a/scripts/gen-must-test-manifest.ts b/scripts/gen-must-test-manifest.ts index b16761e..fe6a834 100644 --- a/scripts/gen-must-test-manifest.ts +++ b/scripts/gen-must-test-manifest.ts @@ -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 = {}; 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; diff --git a/scripts/overnight-soak-ios.sh b/scripts/overnight-soak-ios.sh new file mode 100644 index 0000000..d09531b --- /dev/null +++ b/scripts/overnight-soak-ios.sh @@ -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 "逐用例报告生成失败" diff --git a/scripts/overnight-soak.sh b/scripts/overnight-soak.sh new file mode 100644 index 0000000..37b8540 --- /dev/null +++ b/scripts/overnight-soak.sh @@ -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)" diff --git a/scripts/relay-press-report.ts b/scripts/relay-press-report.ts new file mode 100644 index 0000000..f5eadbe --- /dev/null +++ b/scripts/relay-press-report.ts @@ -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 = {}; +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> = 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}`); +} diff --git a/scripts/relay.ts b/scripts/relay.ts new file mode 100644 index 0000000..ff3e2f9 --- /dev/null +++ b/scripts/relay.ts @@ -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 [--hold ms]'); + await triggerRelayAction(a, 'pairing', hold ? { holdMs: hold } : {}); + break; + } + case 'press': { + if (!a || !b) throw new Error('用法: press