From c67f46992f8a865813a05e62c74941adaee4b7c9 Mon Sep 17 00:00:00 2001 From: woan <798680981@qq.com> Date: Wed, 17 Jun 2026 18:47:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=9A=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=B7=BB=E5=8A=A0=E7=94=A8=E4=BE=8B=20+=20=E7=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=8D=A1=E7=89=87=E6=8E=A7=E5=88=B6(=E4=BA=AE?= =?UTF-8?q?=E5=BA=A6/=E8=89=B2=E6=B8=A9)=20+=20=E9=80=89=E5=93=81=E7=B1=BB?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99=E4=B8=8D=E6=95=8F=E6=84=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加用例(真机验证通过, Android): - 屋檐灯 Permanent Outdoor Lights(ch38/4s, 发现页流程) - URC 万能遥控器(ch20+21 双键同按/4s, 发现页流程) - Relay Switch 1(ch48/2s; 二级选型号 + Skip this guide + Select Device 选设备) - AI Art Frame(ch46/3s, 流程同 AI Hub; 首页名 AI→Al 渲染坑用 "Art Frame" 匹配 + 升级弹框关闭) - Hub 3(ch9+10 双键同按/5s, 发现页流程而非 Hub Mini 流程) - Contact Sensor(ch47/6s 串口配对) - 门铃 Video Doorbell(ch36+37 同按/5s) 注释更正 控制用例: - 灯类首页卡片通用控制 eave_light_control(动态识别电源键+滑条; 亮度+色温; 边缘夹紧防返回手势) 覆盖屋檐灯/RGBICWW Floor Lamp/RGBWW Floor Lamp/RGBWW Strip Light 3/Strip Light 4E/Color Bulb/Ceiling Light Pro helper/config: - selectDeviceCategory 大小写不敏感全字匹配(textMatches (?i)) - config/relay.config.ts 新增 eave_light/urc/strip_light_3/relay_switch/hub3/ai_art_frame 通道 - run-p0-all.sh 纳入上述 ADD/CONTROL Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + .idea/AI_UIAutomation.iml | 8 +- config/account.config.ts | 46 ++ config/device.config.ts | 10 +- config/relay.config.ts | 221 +++++++++- drivers/android-driver.ts | 26 +- drivers/types.ts | 2 + prompts/p0_coverage_report.md | 48 +++ scripts/clean-disk.sh | 45 ++ scripts/debug-add-page.ts | 60 --- scripts/debug-add-shot.ts | 44 -- scripts/debug-bot-scan.ts | 34 -- scripts/debug-curtain-add.ts | 76 ---- scripts/debug-curtain3-cal.ts | 43 -- scripts/debug-find-category.ts | 31 -- scripts/debug-hub-nav.ts | 43 -- scripts/debug-longpress-delete.ts | 43 -- scripts/dump-cats.ts | 28 ++ scripts/dump-home-cams.ts | 24 ++ scripts/dump-ios-cats.ts | 26 ++ scripts/dump-relay-cat.ts | 26 ++ scripts/overnight-noadd.sh | 61 +++ scripts/press-hub3.ts | 5 + scripts/probe-airpurifier.ts | 48 +++ scripts/probe-cbshot.ts | 18 + scripts/probe-colortemp.ts | 37 ++ scripts/probe-ct-nodes.ts | 41 ++ scripts/probe-current.ts | 16 + scripts/probe-hub3-connect.ts | 24 ++ scripts/probe-light-popup.ts | 67 +++ scripts/probe-lights.ts | 20 + scripts/probe-select.ts | 15 + scripts/probe-skip.ts | 16 + scripts/probe-slider.ts | 55 +++ scripts/read-home.ts | 23 + scripts/reset-except.ts | 66 +++ scripts/reset-off.ts | 61 +++ scripts/run-ios-adds.sh | 47 ++ scripts/run-p0-all.sh | 154 +++++++ scripts/start-wda.sh | 2 +- tests/_pm_continue.test.ts | 27 ++ tests/aihub/ai_art_frame_connect.test.ts | 137 ++++++ tests/aihub/aihub_connect.test.ts | 132 +++--- tests/air_purifier/air_purifier_card.test.ts | 161 +++++++ .../air_purifier/air_purifier_connect.test.ts | 94 ++++ tests/auth/account_lifecycle.test.ts | 73 ++++ tests/auth/delete_account.test.ts | 43 ++ tests/auth/eu_login.test.ts | 55 +++ tests/auth/forgot_password.test.ts | 49 +++ tests/auth/login.test.ts | 61 +++ tests/auth/register.test.ts | 44 ++ tests/auth/third_party_login.test.ts | 56 +++ tests/bot/bot_card.test.ts | 254 +++++------ tests/camera/camera_stream.test.ts | 147 +++++++ tests/camera/doorbell_connect.test.ts | 88 ++++ .../ceiling_light_pro_connect.test.ts | 91 ++++ .../color_floor_lamp_connect.test.ts | 92 ++++ .../ceiling_light/eave_light_connect.test.ts | 112 +++++ .../ceiling_light/eave_light_control.test.ts | 251 +++++++++++ .../ceiling_light/floor_lamp_connect.test.ts | 92 ++++ tests/color_bulb/color_bulb_connect.test.ts | 71 +-- tests/curtain/blind_tilt_control.test.ts | 101 +++++ tests/curtain/curtain3_2025_connect.test.ts | 78 ++++ tests/curtain/curtain_connect.test.ts | 30 +- tests/curtain/curtain_control.test.ts | 255 +++-------- tests/find_card/find_card_connect.test.ts | 77 ++++ tests/hub/hub3_connect.test.ts | 99 +++++ tests/hub/hub_control.test.ts | 170 +------- tests/hub/hub_matter_connect.test.ts | 75 ++++ tests/hub/relay_switch_connect.test.ts | 100 +++++ tests/humidifier/humidifier2_card.test.ts | 208 +++++++++ tests/humidifier/humidifier2_connect.test.ts | 95 ++++ tests/keypad/keypad_touch_connect.test.ts | 70 +++ tests/keypad/keypad_vision_connect.test.ts | 71 +++ tests/lock/lock_card.test.ts | 137 +++--- tests/lock/lock_control.test.ts | 194 --------- tests/meter/meter_control.test.ts | 103 ++--- tests/meter/meter_plus_connect.test.ts | 72 ++++ tests/meter/meter_pro_connect.test.ts | 73 ++++ tests/meter/outdoor_meter_connect.test.ts | 3 +- tests/platform/create_room.test.ts | 50 +++ tests/platform/feedback.test.ts | 50 +++ tests/platform/message_center.test.ts | 46 ++ .../rn_plugin.test.ts | 0 tests/platform/scene_operation.test.ts | 104 +++++ tests/plug/plug_mini_connect.test.ts | 88 ++++ tests/plug/plug_mini_control.test.ts | 123 ++++++ .../safety_alarm/safety_alarm_connect.test.ts | 85 ++++ tests/sensor/contact_sensor_connect.test.ts | 75 ++++ tests/sensor/motion_sensor_connect.test.ts | 74 ++++ tests/sensor/presence_sensor_connect.test.ts | 75 ++++ tests/strip_light/neon_light_connect.test.ts | 93 ++++ tests/strip_light/strip_light_connect.test.ts | 77 ++-- tests/urc/urc_connect.test.ts | 112 +++++ .../water_detector_connect.test.ts | 82 ++++ utils/common/account-code.helper.ts | 97 +++++ utils/common/auth.helper.ts | 408 ++++++++++++++++++ utils/common/automation.helper.ts | 198 +++++++++ utils/common/curtain.helper.ts | 99 ++++- utils/common/device-add.helper.ts | 131 ++++-- utils/common/feedback.helper.ts | 124 +++--- utils/common/hub.helper.ts | 85 +++- utils/common/index.ts | 6 + utils/common/message.helper.ts | 38 ++ utils/common/navigation.helper.ts | 52 ++- utils/common/ones-anchor.helper.ts | 38 +- utils/common/preferences.helper.ts | 89 ++++ utils/common/relay.helper.ts | 17 + utils/common/room.helper.ts | 55 +++ utils/common/scene.helper.ts | 76 +++- utils/common/test-resilience.helper.ts | 35 ++ 111 files changed, 6990 insertions(+), 1494 deletions(-) create mode 100644 config/account.config.ts create mode 100644 prompts/p0_coverage_report.md create mode 100755 scripts/clean-disk.sh delete mode 100644 scripts/debug-add-page.ts delete mode 100644 scripts/debug-add-shot.ts delete mode 100644 scripts/debug-bot-scan.ts delete mode 100644 scripts/debug-curtain-add.ts delete mode 100644 scripts/debug-curtain3-cal.ts delete mode 100644 scripts/debug-find-category.ts delete mode 100644 scripts/debug-hub-nav.ts delete mode 100644 scripts/debug-longpress-delete.ts create mode 100644 scripts/dump-cats.ts create mode 100644 scripts/dump-home-cams.ts create mode 100644 scripts/dump-ios-cats.ts create mode 100644 scripts/dump-relay-cat.ts create mode 100755 scripts/overnight-noadd.sh create mode 100644 scripts/press-hub3.ts create mode 100644 scripts/probe-airpurifier.ts create mode 100644 scripts/probe-cbshot.ts create mode 100644 scripts/probe-colortemp.ts create mode 100644 scripts/probe-ct-nodes.ts create mode 100644 scripts/probe-current.ts create mode 100644 scripts/probe-hub3-connect.ts create mode 100644 scripts/probe-light-popup.ts create mode 100644 scripts/probe-lights.ts create mode 100644 scripts/probe-select.ts create mode 100644 scripts/probe-skip.ts create mode 100644 scripts/probe-slider.ts create mode 100644 scripts/read-home.ts create mode 100644 scripts/reset-except.ts create mode 100644 scripts/reset-off.ts create mode 100644 scripts/run-ios-adds.sh create mode 100755 scripts/run-p0-all.sh create mode 100644 tests/_pm_continue.test.ts create mode 100644 tests/aihub/ai_art_frame_connect.test.ts create mode 100644 tests/air_purifier/air_purifier_card.test.ts create mode 100644 tests/air_purifier/air_purifier_connect.test.ts create mode 100644 tests/auth/account_lifecycle.test.ts create mode 100644 tests/auth/delete_account.test.ts create mode 100644 tests/auth/eu_login.test.ts create mode 100644 tests/auth/forgot_password.test.ts create mode 100644 tests/auth/login.test.ts create mode 100644 tests/auth/register.test.ts create mode 100644 tests/auth/third_party_login.test.ts create mode 100644 tests/camera/camera_stream.test.ts create mode 100644 tests/camera/doorbell_connect.test.ts create mode 100644 tests/ceiling_light/ceiling_light_pro_connect.test.ts create mode 100644 tests/ceiling_light/color_floor_lamp_connect.test.ts create mode 100644 tests/ceiling_light/eave_light_connect.test.ts create mode 100644 tests/ceiling_light/eave_light_control.test.ts create mode 100644 tests/ceiling_light/floor_lamp_connect.test.ts create mode 100644 tests/curtain/blind_tilt_control.test.ts create mode 100644 tests/curtain/curtain3_2025_connect.test.ts create mode 100644 tests/find_card/find_card_connect.test.ts create mode 100644 tests/hub/hub3_connect.test.ts create mode 100644 tests/hub/hub_matter_connect.test.ts create mode 100644 tests/hub/relay_switch_connect.test.ts create mode 100644 tests/humidifier/humidifier2_card.test.ts create mode 100644 tests/humidifier/humidifier2_connect.test.ts create mode 100644 tests/keypad/keypad_touch_connect.test.ts create mode 100644 tests/keypad/keypad_vision_connect.test.ts delete mode 100644 tests/lock/lock_control.test.ts create mode 100644 tests/meter/meter_plus_connect.test.ts create mode 100644 tests/meter/meter_pro_connect.test.ts create mode 100644 tests/platform/create_room.test.ts create mode 100644 tests/platform/feedback.test.ts create mode 100644 tests/platform/message_center.test.ts rename tests/{automation => platform}/rn_plugin.test.ts (100%) create mode 100644 tests/platform/scene_operation.test.ts create mode 100644 tests/plug/plug_mini_connect.test.ts create mode 100644 tests/plug/plug_mini_control.test.ts create mode 100644 tests/safety_alarm/safety_alarm_connect.test.ts create mode 100644 tests/sensor/contact_sensor_connect.test.ts create mode 100644 tests/sensor/motion_sensor_connect.test.ts create mode 100644 tests/sensor/presence_sensor_connect.test.ts create mode 100644 tests/strip_light/neon_light_connect.test.ts create mode 100644 tests/urc/urc_connect.test.ts create mode 100644 tests/water_detector/water_detector_connect.test.ts create mode 100644 utils/common/account-code.helper.ts create mode 100644 utils/common/auth.helper.ts create mode 100644 utils/common/automation.helper.ts create mode 100644 utils/common/message.helper.ts create mode 100644 utils/common/preferences.helper.ts create mode 100644 utils/common/test-resilience.helper.ts diff --git a/.gitignore b/.gitignore index 527af01..b9812b3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test_must_case/ test-plan/ # 文档(单独维护,不随代码提交) docs/ +scripts/clean-disk.log diff --git a/.idea/AI_UIAutomation.iml b/.idea/AI_UIAutomation.iml index d0876a7..99ccd0a 100644 --- a/.idea/AI_UIAutomation.iml +++ b/.idea/AI_UIAutomation.iml @@ -1,7 +1,13 @@ - + + + + + + + diff --git a/config/account.config.ts b/config/account.config.ts new file mode 100644 index 0000000..73ac075 --- /dev/null +++ b/config/account.config.ts @@ -0,0 +1,46 @@ +/** + * 登录账号凭据 —— 平台用例(登录/登出/注册收尾登回主账号)用。 + * 分平台配置(Android / iOS 可用不同账号),取值优先级与 wifi.config 一致: + * 平台专用 env(ACCOUNT_EMAIL_ANDROID / ACCOUNT_EMAIL_IOS) > 通用 env(ACCOUNT_EMAIL) > 默认 + * 密码同理(ACCOUNT_PASSWORD_ANDROID / ACCOUNT_PASSWORD_IOS > ACCOUNT_PASSWORD > 默认)。 + * + * 注:注册/忘记密码/注销用例用临时邮箱(account-code.helper)自建账号,不用这里的主账号; + * 主账号仅用于"登录"用例与各流程结尾登回。 + */ +export interface AccountCredentials { + email: string; + password: string; +} + +// 主账号分平台默认(Android / iOS 不同账号,密码相同)。env 可覆盖: +// ACCOUNT_EMAIL_ANDROID / ACCOUNT_EMAIL_IOS > ACCOUNT_EMAIL > 平台默认 +const DEFAULT_EMAIL_ANDROID = 'oap107@uuf.me'; +const DEFAULT_EMAIL_IOS = 'oap108@uuf.me'; +const DEFAULT_PASSWORD = '1234567b'; + +function pick(platformVar: string, genericVar: string, def: string): string { + return process.env[platformVar] || process.env[genericVar] || def; +} + +/** 取当前(或指定)平台的登录账号。platform 缺省读 PLATFORM env(android/ios)。 */ +export function getAccountCredentials(platform?: 'android' | 'ios'): AccountCredentials { + const isIos = (platform || process.env.PLATFORM || 'android').toLowerCase() === 'ios'; + const suffix = isIos ? 'IOS' : 'ANDROID'; + const defEmail = isIos ? DEFAULT_EMAIL_IOS : DEFAULT_EMAIL_ANDROID; + return { + email: pick(`ACCOUNT_EMAIL_${suffix}`, 'ACCOUNT_EMAIL', defEmail), + password: pick(`ACCOUNT_PASSWORD_${suffix}`, 'ACCOUNT_PASSWORD', DEFAULT_PASSWORD), + }; +} + +// 欧区账号(欧区账号登录必测项 ONES 227290 用)。env: ACCOUNT_EMAIL_EU / ACCOUNT_PASSWORD_EU 可覆盖。 +const DEFAULT_EU_EMAIL = 'oeu5@uuf.me'; +const DEFAULT_EU_PASSWORD = '88888888'; + +/** 取欧区登录账号。 */ +export function getEuAccountCredentials(): AccountCredentials { + return { + email: process.env.ACCOUNT_EMAIL_EU || DEFAULT_EU_EMAIL, + password: process.env.ACCOUNT_PASSWORD_EU || DEFAULT_EU_PASSWORD, + }; +} diff --git a/config/device.config.ts b/config/device.config.ts index e1e2b4b..e496026 100644 --- a/config/device.config.ts +++ b/config/device.config.ts @@ -7,8 +7,8 @@ export interface DeviceCategoryConfig { export const DEVICE_CONFIG: Record = { bot: { - devices: ['Bot 0F'], - defaultDevice: 'Bot 0F', + devices: ['Bot 14'], + defaultDevice: 'Bot 14', }, camera: { devices: ['摄像机Plus 3K', 'Pan Tilt', 'Pan Tilt Cam 2K', 'Pan Tilt Plus 2K', 'Pan Tilt Plus 3K', 'Indoor Cam'], @@ -35,7 +35,7 @@ export const DEVICE_CONFIG: Record = { defaultDevice: 'Hub2 8H', }, humidifier: { - devices: ['Humidifier 1K', 'Humidifier2 2L'], + devices: ['Humidifier 1K', 'Evaporative Humidifier'], defaultDevice: 'Humidifier 1K', }, meter: { @@ -46,6 +46,10 @@ export const DEVICE_CONFIG: Record = { devices: ['Contact Sensor 5O', 'Motion Sensor 6P'], defaultDevice: 'Contact Sensor 5O', }, + motion: { + devices: ['Motion Sensor 6P'], + defaultDevice: 'Motion Sensor 6P', + }, ceilingLight: { devices: ['CeilingLight 7Q', 'Ceiling Light pro'], defaultDevice: 'CeilingLight 7Q', diff --git a/config/relay.config.ts b/config/relay.config.ts index 6cc2a6b..64f3194 100644 --- a/config/relay.config.ts +++ b/config/relay.config.ts @@ -29,29 +29,35 @@ export interface RelayDeviceConfig { export const RELAY_MAP: Record = { meter: { - buttons: { main: { android: 33 /*, ios: <填iOS通道> */ } }, + buttons: { main: { android: 32 /*, ios: <填iOS通道> */ } }, actions: { pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键进入添加模式' }, }, }, remote: { buttons: { - convex: { android: 34 /*, ios: <填> */ }, // 凸键 - concave: { android: 35 /*, ios: <填> */ }, // 凹键 + convex: { android: 26 /*, ios: <填> */ }, // 凸键 + concave: { android: 25 /*, ios: <填> */ }, // 凹键 }, actions: { pairing: { buttons: ['convex', 'concave'], holdMs: 2000, desc: '同时按凸键+凹键 2s 进入添加模式(切换式:按一次进、再按退,勿重按)' }, }, }, outdoor_meter: { - buttons: { main: { android: 36 /*, ios: <填> */ } }, + buttons: { main: { android: 28 /*, ios: <填> */ } }, actions: { pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式' }, }, }, + motion: { + buttons: { main: { android: 29 /*, ios: <填> */ } }, // Motion Sensor 配对键(电池仓内按键) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式' }, + }, + }, curtain3: { buttons: { - power: { android: 37 /*, ios: <填> */ }, // 开关按钮 + power: { android: 27 /*, ios: <填> */ }, // 开关按钮 track: { android: 38 /*, ios: <填> */ }, // 控制轨道按钮(控制用,不参与配对) }, actions: { @@ -59,7 +65,7 @@ export const RELAY_MAP: Record = { }, }, curtain: { - buttons: { power: { android: 39 /*, ios: <填> */ } }, // 开关按钮 + buttons: { power: { android: 24 /*, ios: <填> */ } }, // 开关按钮 actions: { pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关按钮 2s 进入添加模式' }, }, @@ -70,6 +76,209 @@ export const RELAY_MAP: Record = { pairing: { buttons: ['main'], holdMs: 4000, desc: '按住主键 4s 进入添加模式(Hub Mini;指引页写3s,实测需4s)' }, }, }, + hub3: { + buttons: { + off: { android: 9 /*, ios: <填> */ }, // 关机键(ch9) + on: { android: 10 /*, ios: <填> */ }, // 开机键(ch10) + }, + actions: { + pairing: { buttons: ['off', 'on'], holdMs: 3000, desc: '同时长按 off+on 键(ch9+10)3s 进入添加模式(Hub 3,WiFi)' }, + }, + }, + relay_switch: { + buttons: { main: { android: 48 /*, ios: <填> */ } }, // Relay Switch 1 配对键(ch48,WiFi 产品) + actions: { + // 连接页明示"press the button for 2s" + pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Relay Switch 1,WiFi)' }, + }, + }, + water: { + buttons: { main: { android: 22 /*, ios: <填> */ } }, // Water Detector(水浸)配对键 + actions: { + // ⚠️ 配对时长待实测;先按 2s 试 + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键进入添加模式(水浸,待实测确认时长)' }, + }, + }, + urc: { + buttons: { + off: { android: 20 /*, ios: <填> */ }, // 关机键(ch20) + on: { android: 21 /*, ios: <填> */ }, // 开机键(ch21) + }, + actions: { + pairing: { buttons: ['off', 'on'], holdMs: 4000, desc: '同时长按 off+on 键(ch20+21)4s 进入添加模式(Universal Remote 万能遥控器)' }, + }, + }, + hubMatter: { + buttons: { main: { android: 23 /*, ios: <填> */ } }, // Hub Mini Matter 主键(添加入口仍是 "Hub Mini") + actions: { + // ⚠️ 配对时长待实测;先沿用 Hub Mini 的 4s + pairing: { buttons: ['main'], holdMs: 4000, desc: '按住主键进入添加模式(Hub Mini Matter,添加入口=Hub Mini;待实测确认时长)' }, + }, + }, + contact: { + buttons: { main: { android: 47 /*, ios: <填> */ } }, // Contact Sensor 配对键(ch47,2026-06 由 44 改;与 plug_mini_eu 同口,同时只接一个) + actions: { + // 实测:长按 6s 进配对(2s/4s 不够,2026-06 确认) + pairing: { buttons: ['main'], holdMs: 6000, desc: '长按配对键 6s 进入添加模式(Contact Sensor)' }, + }, + }, + meter_plus: { + buttons: { main: { android: 31 /*, ios: <填> */ } }, // Meter Plus 主键 + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式(Meter Plus)' }, + }, + }, + find_card: { + buttons: { main: { android: 30 /*, ios: <填> */ } }, // Wallet Finder Card 配对键 + actions: { + // ⚠️ 配对时长待实测 + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键进入添加模式(Wallet Finder Card,待实测确认时长)' }, + }, + }, + plug_mini: { + buttons: { main: { android: 1 /*, ios: <填> */ } }, // Plug Mini (JP) 配对键 + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键2s 进入添加模式(Plug Mini JP,品类"Plug Mini (JP)")' }, + }, + }, + plug_mini_us: { + buttons: { main: { android: 2 /*, ios: <填> */ } }, // Plug Mini (US) 配对键 + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键2s 进入添加模式(Plug Mini US,品类"Plug Mini (US)")' }, + }, + }, + safety_alarm: { + buttons: { main: { android: 3 /*, ios: <填> */ } }, // Safety Alarm 配对键(ch3,BLE 无WiFi,流程近 Find Card) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式(Safety Alarm,BLE无WiFi;选品类后 Disclaimers→Next 再 Connect Device)' }, + }, + }, + meter_pro: { + buttons: { main: { android: 4 /*, ios: <填> */ } }, // Meter Pro (CO2) 配对键(ch4,纯BLE,与 meter 同流程、入口品类名不同) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式(Meter Pro CO2)' }, + }, + }, + presence_sensor: { + buttons: { main: { android: 5 /*, ios: <填> */ } }, // Presence Sensor 配对键(ch5,与 Motion Sensor 同品类组/同流程,入口名不同) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式(Presence Sensor)' }, + }, + }, + curtain3_2025: { + buttons: { power: { android: 6 /*, ios: <填> */ } }, // Curtain 3 2025 开关键(ch6,入口品类=Curtain;校准流程待实测 curtain/curtain3) + actions: { + pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关键 2s 进入添加模式(Curtain 3 2025)' }, + }, + }, + plug_mini_eu: { + buttons: { main: { android: 7 /*, ios: <填> */ } }, // Plug Mini (EU) 配对键(ch7,WiFi 产品,品类“Plug Mini (EU)”) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键2s 进入添加模式(Plug Mini EU,品类“Plug Mini (EU)”)' }, + }, + }, + color_bulb: { + buttons: { main: { android: 2 /*, ios: <填> */ } }, // Color Bulb 配对键(ch2,与 plug_mini_us 共用通道;物理同时只接一个) + actions: { + // 特殊:点一次→等2s→再点一次 进配对(非单次长按)。脚本用 pressButton 两次实现。 + pairing: { buttons: ['main'], holdMs: 400, desc: '点一次→等2s→再点一次 进添加模式(Color Bulb,WiFi 产品)' }, + }, + }, + ceiling_light_pro: { + buttons: { main: { android: 1 /*, ios: <填> */ } }, // 吸顶灯 Pro 配对键(ch1,与 plug_mini JP 共用通道;同时只接一个) + actions: { + // 同 Color Bulb:点一次→等2s→再点一次 进配对(脚本用 pressButton 两次)。 + pairing: { buttons: ['main'], holdMs: 400, desc: '点一次→等2s→再点一次 进添加模式(吸顶灯 Pro,WiFi 产品)' }, + }, + }, + strip_light: { + buttons: { main: { android: 33 /*, ios: <填> */ } }, // Strip Light(灯带)配对键(ch33,灯类,WiFi 产品) + actions: { + // 长按 2s 进配对(灯带,不同于 color bulb 的两次点按)。 + pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Strip Light 灯带,WiFi)' }, + }, + }, + doorbell: { + buttons: { + talk: { android: 36 /*, ios: <填> */ }, // 通话键 + hangup: { android: 37 /*, ios: <填> */ }, // 挂断键 + }, + actions: { + pairing: { buttons: ['talk', 'hangup'], holdMs: 5000, desc: '同时长按 通话+挂断 键 5s 进入添加模式(门铃 Video Doorbell,WiFi)' }, + }, + }, + keypad: { + buttons: { + unlock: { android: 34 /*, ios: <填> */ }, // 解锁键(ch34) + confirm: { android: 35 /*, ios: <填> */ }, // 确定键(ch35) + }, + actions: { + pairing: { buttons: ['unlock', 'confirm'], holdMs: 5000, desc: '同时长按 解锁+确定 键 5s 进入添加模式(Keypad Vision Pro,BLE,ch34+35)' }, + }, + }, + keypad_touch: { + buttons: { + key1: { android: 39 /*, ios: <填> */ }, + key2: { android: 40 /*, ios: <填> */ }, + }, + actions: { + pairing: { buttons: ['key1', 'key2'], holdMs: 3000, desc: '同时长按 39+40 键 3s 进入添加模式(Keypad Touch,BLE)' }, + }, + }, + neon_light: { + buttons: { main: { android: 41 /*, ios: <填> */ } }, // 炫彩霓虹灯(RGBIC Neon Rope)配对键(ch41,灯类WiFi,同灯带流程) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(炫彩霓虹灯,WiFi)' }, + }, + }, + floor_lamp: { + buttons: { main: { android: 42 /*, ios: <填> */ } }, // 落地灯(Floor Lamp)配对键(ch42,灯类WiFi) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(落地灯 Floor Lamp,WiFi)' }, + }, + }, + strip_light_3: { + buttons: { main: { android: 45 /*, ios: <填> */ } }, // 灯带3(RGBWW Strip Light 3)配对键(ch45,灯类WiFi;流程同落地灯) + actions: { + pairing: { buttons: ['main'], holdMs: 4000, desc: '长按配对键 4s 进入添加模式(灯带3 RGBWW strip light 3,WiFi;流程同落地灯)' }, + }, + }, + color_floor_lamp: { + buttons: { main: { android: 44 /*, ios: <填> */ } }, // 炫彩落地灯(RGBIC Floor Lamp)配对键(ch44,灯类WiFi) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(炫彩落地灯 RGBIC Floor Lamp,WiFi)' }, + }, + }, + eave_light: { + buttons: { main: { android: 38 /*, ios: <填> */ } }, // 屋檐灯(Permanent Outdoor Lights)配对键(ch38,灯类WiFi;与 curtain3.track 共用通道,物理同时只接一个) + actions: { + pairing: { buttons: ['main'], holdMs: 4000, desc: '长按配对键 4s 进入添加模式(屋檐灯 Permanent Outdoor Lights,WiFi;流程同落地灯 Floor Lamp)' }, + }, + }, + aihub: { + buttons: { main: { android: 43 /*, ios: <填> */ } }, // AI Hub 配对键(ch43,与 Hub Mini 同口;按一下即可) + actions: { + pairing: { buttons: ['main'], holdMs: 400, desc: '按一下配对键(AI Hub,单次短按)' }, + }, + }, + ai_art_frame: { + buttons: { main: { android: 46 /*, ios: <填> */ } }, // AI Art Frame 配对键(ch46,流程同 AI Hub;长按 3s 进添加模式) + actions: { + pairing: { buttons: ['main'], holdMs: 3000, desc: '长按配对键 3s 进入添加模式(AI Art Frame,流程同 AI Hub)' }, + }, + }, + humidifier2: { + buttons: { main: { android: 63 /*, ios: <填> */ } }, // Evaporative Humidifier(口头"加湿器2")配对键(ch63,带 WiFi 模块) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Evaporative Humidifier,WiFi,实测2s可用)' }, + }, + }, + air_purifier: { + buttons: { main: { android: 62 /*, ios: <填> */ } }, // 空气净化器(Air Purifier)配对键(ch62,带 WiFi 模块;流程同加湿器2) + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Air Purifier,WiFi;流程同 Evaporative Humidifier)' }, + }, + }, }; function currentPlatform(): 'android' | 'ios' { diff --git a/drivers/android-driver.ts b/drivers/android-driver.ts index b432201..a07f974 100644 --- a/drivers/android-driver.ts +++ b/drivers/android-driver.ts @@ -55,6 +55,13 @@ export class AndroidDriver implements DeviceDriver { }).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)); + // 启动后集中清一次启动弹框(SmartThings"发现新设备"、Tips/Fix Now、更新/评分等), + // 避免每个流程点首页按钮前都各自处理。多清两轮以防叠加弹框。 + for (let i = 0; i < 2; i++) { + const cleared = await this.dismissPopupIfPresent().catch(() => false); + if (!cleared) break; + await new Promise(r => setTimeout(r, 800)); + } } async destroySession(): Promise { @@ -68,6 +75,21 @@ export class AndroidDriver implements DeviceDriver { await new Promise(r => setTimeout(r, 2000)); } + /** 重启 App(terminate + activate)复位状态:用例异常失败后调用,避免脏状态拖垮后续用例。 */ + async restartApp(): Promise { + const pkg = APP_CONFIG.android.appPackage; + try { await this.request('POST', `/session/${this.sessionId}/appium/device/terminate_app`, { appId: pkg }); } catch { /* 已退则忽略 */ } + await new Promise(r => setTimeout(r, 1500)); + await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId: pkg }); + await new Promise(r => setTimeout(r, 4000)); + // 重启=冷启动,弹框多且可能延迟/叠加(隐私/更新/Tips/SmartThings/评分)→ 多轮清,每轮留足时间等延迟弹框冒出。 + for (let i = 0; i < 5; i++) { + const cleared = await this.dismissPopupIfPresent().catch(() => false); + await new Promise(r => setTimeout(r, 1200)); + if (!cleared && i >= 2) break; // 至少清 3 轮,之后连续无弹框才停(容忍延迟弹框) + } + } + async findElement(locator: ElementLocator): Promise { if (!locator.android) return null; return this.findElementRaw(locator.android.using, locator.android.value); @@ -328,12 +350,14 @@ export class AndroidDriver implements DeviceDriver { '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', '取消', '关闭', '稍后', '以后再说', '暂不', '暂不更新', '忽略', '跳过', '我知道了', '知道了', '好的', '允许', '不用了', + '不添加', // 三星 SmartThings"发现新设备…是否添加"系统弹框 ]; // 触发:对话框/常见弹框关键字(扩充:更新/评分/新功能/通知/试用/优惠/连接失败 等) 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', + 'Please Note', 'Restart', 'Got it', 'Rate', 'Review', 'Reminder', 'Notification', 'Subscribe', 'Trial', 'Welcome', 'Tips', 'Fix Now', 'Failed to connect', 'connect to your device', 'Connection failed', 'Try Again', + 'SmartThings', '发现新设备', '添加至', // 三星系统层的设备发现弹框 '更新', '升级', '新功能', '评分', '评价', '提示', '通知', '订阅', '试用', '体验', '优惠', '连接失败', '请注意', '温馨提示', ]; let dismissed = false; diff --git a/drivers/types.ts b/drivers/types.ts index 8d67f36..e6eb487 100644 --- a/drivers/types.ts +++ b/drivers/types.ts @@ -54,4 +54,6 @@ export interface DeviceDriver { isOnHomepage(): Promise; goBackToHomepage(): Promise; dismissPopupIfPresent(): Promise; + /** 重启 App(terminate + activate)复位状态。可选:仅部分平台实现(Android)。 */ + restartApp?(): Promise; } diff --git a/prompts/p0_coverage_report.md b/prompts/p0_coverage_report.md new file mode 100644 index 0000000..64a104b --- /dev/null +++ b/prompts/p0_coverage_report.md @@ -0,0 +1,48 @@ +# P0 覆盖情况生成提示词 + +> 用途:让 Claude Code 自动盘点 SwitchBot App UI 自动化的 **P0 必测项覆盖情况**,输出"已覆盖 / 缺口"报告。 +> 用法:在仓库根目录(`/Users/woan/Desktop/AI_UIAutomation`)直接把下面【提示词】整段发给 Claude 即可。 + +--- + +## 【提示词】 + +盘点 P0 必测项(ONES 计划 CQz9YCNX / lib EPfZfC9Y)的自动化覆盖情况,输出覆盖/缺口报告。按以下步骤与规则执行: + +### 数据源 +1. **P0 必测项全集** = `test-plan/must-test.manifest.ts`(187 条;字段:`kind`(add/feature/ctrl/platform)、`ones`、`name`、`cat`、`device`、`file`、`testName`、`status`)。 + - 构成:添加 add ≈73、单品功能 feature 9、控制 ctrl=2 条协议超级用例逐 step(15975 BLE 49 步 + 15974 WiFi 56 步 = 105)、platform 12。另有 2 条 `status:"na"`(safety alarm 无目录、强绑定解绑归平台)。 +2. **实际已实现用例** = `tests/<品类>/*.test.ts`(尤其 `*connect*` = 添加、`*control*` = 控制)。 +3. **已调通基线(回归集)** = `scripts/run-p0-all.sh` 的 `ADD_CMDS` / `CONTROL` / `TAIL` 列表 —— 在里面的都是已调试通过、纳入回归的。 + +### 判定规则(重要) +- **不要信 manifest 的 `status` 字段**(长期是 `todo`,没维护)。覆盖与否以"是否真有对应用例文件且已调通"为准。 +- **`manifest.file` 是旧的"计划映射"**(多指向通用 `xxx_connect.test.ts`),实际实现常拆成型号专属文件(如 `presence_sensor_connect.test.ts`、`meter_pro_connect.test.ts`、`color_floor_lamp_connect.test.ts`)。要按 **设备/型号名** 比对,而不是按文件名。 +- **"无 [P0]/ONES 标记" ≠ 未覆盖**:meter / curtain / hub_matter / find_card / water / remote / plug_mini / motion / contact 等虽未打 ONES 标记,但已调通且在 `run-p0-all.sh` 回归里 → 算已覆盖。判"已覆盖"时务必交叉核对 `run-p0-all.sh`。 +- **某品类缺整个 connect 文件** = 该品类所有添加项缺口(例:无 `tests/camera/camera_connect.test.ts` → 除门铃外所有摄像机添加缺口;无 `tests/urc/urc_connect.test.ts` → URC 添加缺口)。 +- **有 connect 文件但是骨架/未调/无硬件**(如 robot/lock/hub/humidifier/fan 的添加)算"未覆盖(未调试)",并注明原因(无硬件 / 未调试)。 +- 控制 ctrl 粒度在 step:已覆盖 = 该 step 所属品类的 `*_control.test.ts` 已调通对应动作;否则列入未覆盖,并按"出流/清扫/开关/校准/绑定"等动作归类。 + +### 执行动作(尽量省 token) +1. 读 `test-plan/must-test.manifest.ts` 拿全集。 +2. `ls tests/*/*.test.ts` + 检测各 `*connect*` 是否存在、是否带 `[P0]`/`ONES:` 标记。 +3. 读 `scripts/run-p0-all.sh` 的 ADD/CONTROL/TAIL 列表。 +4. 交叉比对,产出报告。**不需要真机运行**,纯静态盘点。 + +### 输出格式 +1. **顶部一行汇总**:P0 总数、已覆盖数、缺口数(add / feature / ctrl / platform 分别多少)。 +2. **✅ 已覆盖的添加**(列品类/型号)。 +3. **❌ P0 添加缺口表**:列 `分组 | 缺的型号 | 原因`(原因:无 connect 文件 / 未调试 / 无硬件 / 未实现)。 +4. **❌ 单品功能 feature 缺口**(列 ONES 号 + 名);并列已做的。 +5. **❌ 平台 platform 缺口**;并列已做的。 +6. **⚠️ 控制 ctrl(105 step)**:已调通的动作类 + 大量未覆盖动作类。 +7. **结尾"优先补的建议"**:按"设备在手 + 无文件"优先(如 camera_connect、urc_connect、锁/Hub/加湿器/循环扇添加),硬件受限的(扫地机、融蜡灯需110V)后置。 + +报告用中文、表格化、可扫读;不要逐条罗列 105 个 step,按动作归类即可。 + +--- + +## 备注(维护者看) +- 新调通一个设备后:① 在用例打 `[P0][ONES:<号>]` 标记;② 视情况加进 `scripts/run-p0-all.sh` 的 ADD/CONTROL 列表 —— 这样本提示词能自动算它"已覆盖"。 +- 若要让 manifest `status` 也准,可另写脚本按上述规则回填 `must-test.manifest.ts` 的 `status`(todo/done),再用本提示词时可直接信 status。 +- 最近(2026-06)新增已覆盖:门铃、keypad vision pro(ch34+35)、keypad touch、炫彩霓虹灯、RGBWW落地灯、RGBICWW炫彩落地灯(ch44)、presence、meter pro co2、curtain3 2025、safety alarm、plug mini EU、吸顶灯Pro、color bulb、strip light。AI Hub(ch43)流程就绪但继电器接线未生效(硬件阻塞)。 diff --git a/scripts/clean-disk.sh b/scripts/clean-disk.sh new file mode 100755 index 0000000..1dbec79 --- /dev/null +++ b/scripts/clean-disk.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# 定期清理测试生成物 + PyCharm freeze 转储,防止"系统数据"被打满。 +# 由 crontab 每天调度(见文件末尾安装说明)。详见记忆 reference-midscene-disk-growth。 +set -u +PROJ="/Users/woan/Desktop/AI_UIAutomation" +LOG="$PROJ/scripts/clean-disk.log" + +{ + echo "===== $(date '+%F %T') 开始清理 =====" + + # 1. midscene 报告:纯生成产物,全清 + if [ -d "$PROJ/midscene_run" ]; then + sz=$(du -sh "$PROJ/midscene_run" 2>/dev/null | cut -f1) + rm -rf "$PROJ/midscene_run"/* 2>/dev/null + echo "midscene_run 清理前 $sz → 0" + fi + + # 2. reports:只保留"最近一次测试报告"(最新 AIHub_*.html 及其同一次运行的截图/日志)。 + # 以第 2 新的报告为分界,比它旧的全删。 + if [ -d "$PROJ/reports" ]; then + cutoff=$(ls -t "$PROJ/reports"/AIHub_*.html 2>/dev/null | sed -n '2p') + if [ -n "$cutoff" ] && [ -f "$cutoff" ]; then + n=$(find "$PROJ/reports" -type f ! -newer "$cutoff" 2>/dev/null | wc -l | tr -d ' ') + find "$PROJ/reports" -type f ! -newer "$cutoff" -delete 2>/dev/null + find "$PROJ/reports" -type d -empty -delete 2>/dev/null + echo "reports 只留最近一次,删除旧文件 $n 个" + else + echo "reports 报告数 <=1,不清理" + fi + fi + + # 3. PyCharm freeze 转储:安全网(IDE 卡死会狂堆,实测堆到 23G) + dumps=$(ls -d ~/Library/Logs/JetBrains/PyCharm*/threadDumps-* 2>/dev/null | wc -l | tr -d ' ') + rm -rf ~/Library/Logs/JetBrains/PyCharm*/threadDumps-* 2>/dev/null + echo "PyCharm 转储目录清理 $dumps 个" + + echo "可用空间: $(df -h / | tail -1 | awk '{print $4}')" +} >> "$LOG" 2>&1 + +# 只保留日志最后 200 行 +tail -n 200 "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG" + +# ---- 安装(每 2 周跑一次:每月 1 号、15 号 04:13)---- +# chmod +x scripts/clean-disk.sh +# (crontab -l 2>/dev/null; echo "13 4 1,15 * * /Users/woan/Desktop/AI_UIAutomation/scripts/clean-disk.sh") | crontab - diff --git a/scripts/debug-add-page.ts b/scripts/debug-add-page.ts deleted file mode 100644 index 6e48d55..0000000 --- a/scripts/debug-add-page.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index b51d193..0000000 --- a/scripts/debug-add-shot.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 3458c9e..0000000 --- a/scripts/debug-bot-scan.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 19f7194..0000000 --- a/scripts/debug-curtain-add.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 484be8a..0000000 --- a/scripts/debug-curtain3-cal.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index f217368..0000000 --- a/scripts/debug-find-category.ts +++ /dev/null @@ -1,31 +0,0 @@ -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-hub-nav.ts b/scripts/debug-hub-nav.ts deleted file mode 100644 index 81500d2..0000000 --- a/scripts/debug-hub-nav.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createDriver } from '../drivers/factory'; -import { sleep } from '../utils/common'; -import * as dotenv from 'dotenv'; -import * as path from 'path'; - -dotenv.config({ path: path.resolve(__dirname, '../.env') }); - -async function debug() { - const driver = createDriver(); - await driver.createSession(); - console.log('Session created, platform:', driver.platform); - - await sleep(2000); - const source = await driver.getSource(); - console.log('Source length:', source.length); - console.log('Has "AI Hub 6C":', source.includes('AI Hub 6C')); - console.log('Has "Home":', source.includes('Home')); - console.log('Has "Add":', source.includes('Add')); - - // Try findDeviceCard - const hubEl = await (driver as any).findDeviceCard('AI Hub 6C'); - console.log('findDeviceCard result:', hubEl); - - if (hubEl) { - const rect = await driver.getElementRect(hubEl); - console.log('Element rect:', rect); - // Try tapping it - await driver.tap(rect.x + 100, rect.y + 30); - await sleep(5000); - const s2 = await driver.getSource(); - console.log('After tap - Has "Try OpenClaw":', s2.includes('Try OpenClaw')); - console.log('After tap - Has "Cameras":', s2.includes('Cameras')); - console.log('After tap - Has "AI Events":', s2.includes('AI Events')); - } else { - // Check what's visible - console.log('--- First 2000 chars of source ---'); - console.log(source.substring(0, 2000)); - } - - await driver.destroySession(); -} - -debug().catch(e => { console.error('ERROR:', e.message); process.exit(1); }); diff --git a/scripts/debug-longpress-delete.ts b/scripts/debug-longpress-delete.ts deleted file mode 100644 index eca2a98..0000000 --- a/scripts/debug-longpress-delete.ts +++ /dev/null @@ -1,43 +0,0 @@ -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-cats.ts b/scripts/dump-cats.ts new file mode 100644 index 0000000..0f6968f --- /dev/null +++ b/scripts/dump-cats.ts @@ -0,0 +1,28 @@ +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const d = createDriver(); + await d.createSession(); + await navigateToAddPage(d, 'Device'); + await sleep(1500); + const found = new Set(); + for (let i = 0; i < 20; i++) { + // 展开 Show More(若有) + const more = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Show More")'); + if (more) { await d.tapElement(more); await sleep(1500); } + const s = await d.getSource(); + const m = s.match(/(?:text|content-desc)="([^"]+)"/g) || []; + m.forEach((x) => { + const v = x.replace(/^(?:text|content-desc)="/, '').replace(/"$/, ''); + if (v && v.length < 45 && /Lamp|Floor|Strip|Light|Glow|Tube|LED|Lantern|Bulb|RGBIC|Neon|Wall/i.test(v)) found.add(v); + }); + await d.scrollDown(500); + await sleep(700); + } + console.log('=== 灯类相关品类(展开后) ==='); + console.log([...found].sort().join('\n')); + await d.destroySession(); +})(); diff --git a/scripts/dump-home-cams.ts b/scripts/dump-home-cams.ts new file mode 100644 index 0000000..eb5016d --- /dev/null +++ b/scripts/dump-home-cams.ts @@ -0,0 +1,24 @@ +import { createDriver } from '../drivers/factory'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const d = createDriver(); + await d.createSession(); + await d.goBackToHomepage().catch(() => {}); + await sleep(1500); + const found = new Set(); + for (let i = 0; i < 10; i++) { + const s = await d.getSource(); + const m = s.match(/(?:text|content-desc)="([^"]+)"/g) || []; + m.forEach((x) => { + const v = x.replace(/^(?:text|content-desc)="/, '').replace(/"$/, ''); + if (v && v.length < 40 && /Cam|Camera|Doorbell|OSC|Outdoor|Pan|Tilt|摄像|门铃/i.test(v)) found.add(v); + }); + await d.scrollDown(600); + await sleep(800); + } + console.log('=== 首页摄像头相关卡片名 ==='); + console.log([...found].sort().join('\n')); + await d.destroySession(); +})(); diff --git a/scripts/dump-ios-cats.ts b/scripts/dump-ios-cats.ts new file mode 100644 index 0000000..a61e636 --- /dev/null +++ b/scripts/dump-ios-cats.ts @@ -0,0 +1,26 @@ +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const d = createDriver(); + await d.createSession(); + const ok = await navigateToAddPage(d, 'Device'); + console.log('navigateToAddPage:', ok); + await sleep(1500); + const found = new Set(); + for (let i = 0; i < 16; i++) { + const s = await d.getSource(); + const m = s.match(/(?:label|name)="([^"]*(?:Keypad|Vision|Lock|Key )[^"]*)"/g) || []; + m.forEach((x) => { + const v = x.replace(/^(?:label|name)="/, '').replace(/"$/, ''); + if (v && v.length < 45) found.add(v); + }); + await d.swipe(195, 650, 195, 250, 0.5).catch(() => {}); + await sleep(700); + } + console.log('=== Keypad/Vision/Lock 入口名 ==='); + console.log([...found].sort().join('\n')); + await d.destroySession(); +})(); diff --git a/scripts/dump-relay-cat.ts b/scripts/dump-relay-cat.ts new file mode 100644 index 0000000..d445536 --- /dev/null +++ b/scripts/dump-relay-cat.ts @@ -0,0 +1,26 @@ +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const d = createDriver(); + await d.createSession(); + await navigateToAddPage(d, 'Device'); + await sleep(1500); + const found = new Set(); + for (let i = 0; i < 22; i++) { + const more = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Show More")').catch(() => null); + if (more) { await d.tapElement(more); await sleep(1200); } + const s = await d.getSource(); + for (const x of s.match(/(?:text|content-desc)="([^"]+)"/g) || []) { + const v = x.replace(/^(?:text|content-desc)="/, '').replace(/"$/, ''); + if (v && v.length < 45 && /relay|switch|plug/i.test(v)) found.add(v); + } + await d.scrollDown(500).catch(() => {}); + await sleep(700); + } + console.log('=== 含 relay/switch/plug 的品类条目 ==='); + console.log(JSON.stringify([...found].sort())); + await d.destroySession(); +})(); diff --git a/scripts/overnight-noadd.sh b/scripts/overnight-noadd.sh new file mode 100755 index 0000000..166fd95 --- /dev/null +++ b/scripts/overnight-noadd.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# 过夜执行已调试好的非添加 P0 用例,结果合并进下午的添加报告。 +# 机制:SOAK_MODE=1 → global-setup 不清空/不每轮出报告,结果累积到 reports/.results.json; +# 末尾 gen-combined 出一份合并报告。先回填下午添加的结果(已被后续调试清掉)。 +# 用 nohup 跑,不经 Claude:无授权弹框、不烧 token。日志见 reports/overnight-noadd.log。 +set -u +cd /Users/woan/Desktop/AI_UIAutomation +LOG=reports/overnight-noadd.log +export PLATFORM=android +export SOAK_MODE=1 # 累积结果、不每轮清空/出报告 +export SKIP_PROTO_NET=1 # bot_card 跳过蓝牙/WiFi 协议切换(adb dump 偶发被杀) + +{ +echo "===== $(date '+%F %T') 过夜非添加用例开始 =====" + +# 1. 回填下午添加报告结果(9 PASS + Hub Mini SKIP[已移 iOS]) +cat > reports/.results.json <<'JSON' +{ + "platform": "ANDROID", + "results": [ + {"suite":"Bot_Connect","name":"[P0][ONES:15968] 添加Bot 14","status":"PASS","duration":31200,"detail":"Bot 14添加成功(下午)"}, + {"suite":"Meter_Connect","name":"[P0][ONES:15965] 添加Meter设备","status":"PASS","duration":51400,"detail":"Meter 0B添加成功(下午)"}, + {"suite":"MeterPlus_Connect","name":"[P0][ONES:15956] 添加Meter Plus设备","status":"PASS","duration":47900,"detail":"Meter Plus C9添加成功(下午)"}, + {"suite":"OutdoorMeter_Connect","name":"添加Outdoor Meter设备(套件)","status":"PASS","duration":90900,"detail":"Outdoor Meter 6A添加成功(下午)"}, + {"suite":"MotionSensor_Connect","name":"[P0][ONES:15963] 添加Motion Sensor设备","status":"PASS","duration":57100,"detail":"Motion Sensor A5添加成功(下午)"}, + {"suite":"Remote_Connect","name":"添加Remote设备","status":"PASS","duration":48600,"detail":"Remote 63添加成功(下午)"}, + {"suite":"Curtain_Connect","name":"[P0][ONES:15969] 添加窗帘设备","status":"PASS","duration":73200,"detail":"Curtain添加成功(下午)"}, + {"suite":"WaterDetector_Connect","name":"添加水浸设备(BLE+WiFi)","status":"PASS","duration":98500,"detail":"Water Leak Detector 62添加成功(下午)"}, + {"suite":"HubMiniMatter_Connect","name":"[P0][ONES:65336] 添加Hub Mini Matter","status":"PASS","duration":109100,"detail":"HubMiniMatter 0J添加成功(下午)"}, + {"suite":"Hub_Connect","name":"[P0][ONES:21787] 添加Hub Mini设备","status":"SKIP","duration":5900,"detail":"Hub Mini 移至 iOS 执行"} + ] +} +JSON +echo "已回填下午添加结果(10 条)" + +# 2. 逐个串行跑非添加用例(分开调用保证顺序:设备/平台在前=保持 oap107 登录态,账号类在后) +TESTS=( + tests/meter/meter_control.test.ts + tests/bot/bot_card.test.ts + tests/platform/scene_operation.test.ts + tests/platform/create_room.test.ts + tests/platform/message_center.test.ts + tests/platform/feedback.test.ts + tests/auth/third_party_login.test.ts + tests/auth/eu_login.test.ts + tests/auth/register.test.ts + tests/auth/forgot_password.test.ts + tests/auth/delete_account.test.ts + tests/auth/login.test.ts +) +for t in "${TESTS[@]}"; do + echo "----- $(date '+%T') 跑 $t -----" + npx vitest run "$t" 2>&1 | grep -E '✓ tests|✗ tests|结果:|通过率|SKIP|PASS|FAIL' | tail -25 || true +done + +# 3. 出合并报告(下午添加 + 今晚非添加) +echo "----- 生成合并报告 -----" +npx ts-node scripts/gen-combined.ts AIHub_Android_All + +echo "===== $(date '+%F %T') 完成 =====" +} >> "$LOG" 2>&1 diff --git a/scripts/press-hub3.ts b/scripts/press-hub3.ts new file mode 100644 index 0000000..7089933 --- /dev/null +++ b/scripts/press-hub3.ts @@ -0,0 +1,5 @@ +import { enterPairingMode } from '../utils/common/relay.helper'; +(async () => { + await enterPairingMode('hub3', { holdMs: 3000 }); + console.log('已长按 ch9+10 3s'); +})(); diff --git a/scripts/probe-airpurifier.ts b/scripts/probe-airpurifier.ts new file mode 100644 index 0000000..2792510 --- /dev/null +++ b/scripts/probe-airpurifier.ts @@ -0,0 +1,48 @@ +import { createDriver } from '../drivers/factory'; +import * as fs from 'fs'; + +(async () => { + const driver = createDriver(); + await driver.createSession(); + const KW = process.env.AIR_PURIFIER_CARD || 'Air Purifier'; + try { + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); + await new Promise((r) => setTimeout(r, 1000)); + // 找卡片并点中心 + let el: string | null = null; + for (let i = 0; i < 8; i++) { + el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${KW}")`).catch(() => null); + if (el) break; + await driver.swipe(540, 1400, 540, 600, 0.4).catch(() => {}); + await new Promise((r) => setTimeout(r, 900)); + } + if (!el) throw new Error('找不到卡片'); + const rect = await driver.getElementRect(el); + console.log(`卡片文本 rect=(${rect.x},${rect.y}) ${rect.width}x${rect.height}`); + await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); + await new Promise((r) => setTimeout(r, 2500)); + + const size = await driver.getWindowSize(); + console.log(`屏幕 ${size.width}x${size.height}`); + const src = await driver.getSource(); + const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t.trim()); + console.log('=== 浮层文本节点 ==='); + console.log(JSON.stringify([...new Set(texts)], null, 0)); + + // 把带 bounds 的可点节点也 dump 出来(找无文案电源键的可能容器) + const nodes = Array.from(src.matchAll(/<[^>]*?(?:text="([^"]*)")?[^>]*?(?:content-desc="([^"]*)")?[^>]*?clickable="true"[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"[^>]*?>/g)); + console.log('=== clickable 节点(text|desc|bounds) ==='); + for (const n of nodes.slice(0, 40)) console.log(` "${n[1] || ''}" | "${n[2] || ''}" | ${n[3]}`); + + const ss = await driver.screenshot(); + fs.writeFileSync('/tmp/airpurifier_popup.png', Buffer.from(ss, 'base64')); + console.log('截图: /tmp/airpurifier_popup.png'); + } catch (e: any) { + console.error('探针失败:', e.message); + const ss = await driver.screenshot().catch(() => ''); + if (ss) fs.writeFileSync('/tmp/airpurifier_popup.png', Buffer.from(ss, 'base64')); + } finally { + await driver.destroySession(); + } +})(); diff --git a/scripts/probe-cbshot.ts b/scripts/probe-cbshot.ts new file mode 100644 index 0000000..5585262 --- /dev/null +++ b/scripts/probe-cbshot.ts @@ -0,0 +1,18 @@ +import { createDriver } from '../drivers/factory'; +import * as fs from 'fs'; +(async () => { + const d = createDriver(); await d.createSession(); + const KW='Color Bulb 7E', W=1080,H=2400; const sleep=(m:number)=>new Promise(r=>setTimeout(r,m)); + try { + await d.dismissPopupIfPresent(); await d.goBackToHomepage(); await sleep(800); + let el=null; for(let i=0;i<10;i++){el=await d.findElementRaw('-android uiautomator',`new UiSelector().textContains("${KW}")`).catch(()=>null); if(el)break; await d.swipe(540,1500,540,700,0.4).catch(()=>{}); await sleep(800);} + const r=await d.getElementRect(el!); await d.tap(r.x+r.width/2,r.y+r.height/2); await sleep(2500); + const src=await d.getSource(); + const pw=Array.from(src.matchAll(/clickable="true"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g)).map(m=>{const x1=+m[1],y1=+m[2],x2=+m[3],y2=+m[4];return{w:x2-x1,h:y2-y1,cx:(x1+x2)/2,cy:(y1+y2)/2};}).filter(b=>b.w>=150&&b.w<=320&&b.h>=150&&b.h<=320&&Math.abs(b.cx-W/2)H*0.4); + const st=()=>d.getSource().then(s=>{const t=Array.from(s.matchAll(/text="([^"]+)"/g)).map(m=>m[1]);const i=t.findIndex(x=>x.includes(KW));return i>=0?t[i+1]||'':'';}); + if(!/\d+\s*%/.test(await st()) && pw.length){await d.tap(Math.round(pw[0].cx),Math.round(pw[0].cy)); await sleep(3500);} + const texts=[...new Set(Array.from((await d.getSource()).matchAll(/text="([^"]+)"/g)).map(m=>m[1]).filter(t=>t.trim()))]; + console.log('TEXTS='+JSON.stringify(texts)); + const ss=await d.screenshot(); fs.writeFileSync('/tmp/cb_popup.png',Buffer.from(ss,'base64')); console.log('OK'); + } catch(e:any){console.error(e.message);} finally{await d.destroySession();} +})(); diff --git a/scripts/probe-colortemp.ts b/scripts/probe-colortemp.ts new file mode 100644 index 0000000..f20fb7d --- /dev/null +++ b/scripts/probe-colortemp.ts @@ -0,0 +1,37 @@ +import { createDriver } from '../drivers/factory'; + +(async () => { + const d = createDriver(); + await d.createSession(); + const KW = process.env.LIGHT_CARD || 'Color Bulb 7E'; + const W = 1080, H = 2400; + const sleep = (m: number) => new Promise((r) => setTimeout(r, m)); + const fullSub = async (): Promise => { + const s = await d.getSource(); + const t = Array.from(s.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); + const i = t.findIndex((x) => x.includes(KW)); + return i >= 0 ? `${t[i + 1] || ''} | ${t[i + 2] || ''} | ${t[i + 3] || ''}` : ''; + }; + try { + await d.dismissPopupIfPresent(); await d.goBackToHomepage(); await sleep(800); + let el: string | null = null; + for (let i = 0; i < 10; i++) { el = await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${KW}")`).catch(() => null); if (el) break; await d.swipe(540, 1500, 540, 700, 0.4).catch(() => {}); await sleep(800); } + const r = await d.getElementRect(el!); await d.tap(r.x + r.width / 2, r.y + r.height / 2); await sleep(2500); + // 开灯 + const src = await d.getSource(); + const pw = Array.from(src.matchAll(/clickable="true"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g)) + .map((m) => { const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4]; return { w: x2 - x1, h: y2 - y1, cx: (x1 + x2) / 2, cy: (y1 + y2) / 2 }; }) + .filter((b) => b.w >= 150 && b.w <= 320 && b.h >= 150 && b.h <= 320 && Math.abs(b.cx - W / 2) < W * 0.15 && b.cy > H * 0.4); + if (!/\d+\s*%/.test(await fullSub()) && pw.length) { await d.tap(Math.round(pw[0].cx), Math.round(pw[0].cy)); await sleep(3500); } + console.log('开灯后副标题(含后2字段)=' + JSON.stringify(await fullSub())); + + // 色温条 y1737:拖最左 / 中 / 最右,各看副标题 + const left = 58, right = 1022, y = 1737; + for (const [lbl, f] of [['左', 0.05], ['中', 0.5], ['右', 0.95]] as Array<[string, number]>) { + // 从中点起拖(色温滑块当前位置未知,先试中点抓) + await d.swipe(540, y, Math.round(left + f * (right - left)), y, 0.8); await sleep(2000); + console.log(`色温拖${lbl}(f=${f}) → 副标题=${JSON.stringify(await fullSub())}`); + } + } catch (e: any) { console.error('FAIL:', e.message); } + finally { await d.destroySession(); } +})(); diff --git a/scripts/probe-ct-nodes.ts b/scripts/probe-ct-nodes.ts new file mode 100644 index 0000000..4de38e3 --- /dev/null +++ b/scripts/probe-ct-nodes.ts @@ -0,0 +1,41 @@ +import { createDriver } from '../drivers/factory'; + +(async () => { + const d = createDriver(); + await d.createSession(); + const KW = process.env.LIGHT_CARD || 'Color Bulb 7E'; + const W = 1080, H = 2400; + const sleep = (m: number) => new Promise((r) => setTimeout(r, m)); + const sub = async (): Promise => { + const s = await d.getSource(); + const t = Array.from(s.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); + const i = t.findIndex((x) => x.includes(KW)); + return i >= 0 ? t[i + 1] || '' : ''; + }; + // dump y1650-1825 区间所有节点(class + bounds),找会随拖动移动的子节点 + const dumpTrack = async (y1lo: number, y1hi: number): Promise => { + const s = await d.getSource(); + return Array.from(s.matchAll(/<[^>]*class="([^"]+)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*>/g)) + .map((m) => ({ cls: m[1].split('.').pop(), x1: +m[2], y1: +m[3], x2: +m[4], y2: +m[5] })) + .filter((b) => b.y1 >= y1lo && b.y2 <= y1hi) + .map((b) => `${b.cls} [${b.x1},${b.y1}][${b.x2},${b.y2}] w${b.x2 - b.x1}`); + }; + try { + await d.dismissPopupIfPresent(); await d.goBackToHomepage(); await sleep(800); + let el: string | null = null; + for (let i = 0; i < 10; i++) { el = await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${KW}")`).catch(() => null); if (el) break; await d.swipe(540, 1500, 540, 700, 0.4).catch(() => {}); await sleep(800); } + const r = await d.getElementRect(el!); await d.tap(r.x + r.width / 2, r.y + r.height / 2); await sleep(2500); + const src = await d.getSource(); + const pw = Array.from(src.matchAll(/clickable="true"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g)) + .map((m) => { const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4]; return { w: x2 - x1, h: y2 - y1, cx: (x1 + x2) / 2, cy: (y1 + y2) / 2 }; }) + .filter((b) => b.w >= 150 && b.w <= 320 && b.h >= 150 && b.h <= 320 && Math.abs(b.cx - W / 2) < W * 0.15 && b.cy > H * 0.4); + if (!/\d+\s*%/.test(await sub()) && pw.length) { await d.tap(Math.round(pw[0].cx), Math.round(pw[0].cy)); await sleep(3500); } + + const left = 58, right = 1022, y = 1737; + await d.swipe(540, y, left, y, 0.8); await sleep(1800); + console.log('色温拖最左, track节点=' + JSON.stringify(await dumpTrack(1620, 1860))); + await d.swipe(left, y, right, y, 0.8); await sleep(1800); + console.log('色温拖最右, track节点=' + JSON.stringify(await dumpTrack(1620, 1860))); + } catch (e: any) { console.error('FAIL:', e.message); } + finally { await d.destroySession(); } +})(); diff --git a/scripts/probe-current.ts b/scripts/probe-current.ts new file mode 100644 index 0000000..123e4f3 --- /dev/null +++ b/scripts/probe-current.ts @@ -0,0 +1,16 @@ +import { createDriver } from '../drivers/factory'; + +(async () => { + const d = createDriver(); + await d.createSession(); + try { + const src = await d.getSource(); + const texts = [...new Set(Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t.trim()))]; + console.log('当前页文案=' + JSON.stringify(texts)); + // clickable 节点的 text/desc + const clk = Array.from(src.matchAll(/<[^>]*?(?:text="([^"]*)")?[^>]*?(?:content-desc="([^"]*)")?[^>]*?clickable="true"[^>]*?>/g)) + .map((m) => `${m[1] || ''}|${m[2] || ''}`).filter((s) => s !== '|'); + console.log('可点按钮=' + JSON.stringify([...new Set(clk)].slice(0, 25))); + } catch (e: any) { console.error('FAIL:', e.message); } + finally { await d.destroySession(); } +})(); diff --git a/scripts/probe-hub3-connect.ts b/scripts/probe-hub3-connect.ts new file mode 100644 index 0000000..7e14ed4 --- /dev/null +++ b/scripts/probe-hub3-connect.ts @@ -0,0 +1,24 @@ +import { createDriver } from '../drivers/factory'; +import { enterPairingMode } from '../utils/common/relay.helper'; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const dump = async (d: any): Promise => [...new Set((Array.from((await d.getSource()).matchAll(/text="([^"]+)"/g)) as any[]).map((m) => m[1]).filter((t: string) => t.trim()))] as string[]; +(async () => { + const d = createDriver(); + await d.createSession(); + try { + await enterPairingMode('hub3').catch((e) => console.log('relay 失败:', e.message)); + console.log('已按 ch9+10 3s'); + await sleep(2000); + for (const t of ['Connect device', 'Connect Device', 'Connect Devices']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { await d.tapElement(b); console.log(`已点 ${t}`); break; } + } + for (let i = 0; i < 12; i++) { + await sleep(5000); + const texts = await dump(d); + console.log(`+${(i + 1) * 5}s: ${JSON.stringify(texts)}`); + if (/Configure Wi-?Fi|SSID|Select Device|Pick a room|added successfully/i.test(texts.join(' '))) { console.log('==关键页=='); break; } + } + } catch (e: any) { console.error('FAIL:', e.message); } + finally { await d.destroySession(); } +})(); diff --git a/scripts/probe-light-popup.ts b/scripts/probe-light-popup.ts new file mode 100644 index 0000000..b62266a --- /dev/null +++ b/scripts/probe-light-popup.ts @@ -0,0 +1,67 @@ +import { createDriver } from '../drivers/factory'; +import * as fs from 'fs'; + +(async () => { + const d = createDriver(); + await d.createSession(); + const KW = process.env.LIGHT_CARD || 'Permanent Outdoor Lights'; + try { + await d.dismissPopupIfPresent(); await d.goBackToHomepage(); + await new Promise((r) => setTimeout(r, 1000)); + let el: string | null = null; + for (let i = 0; i < 10; i++) { + el = await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${KW}")`).catch(() => null); + if (el) break; + await d.swipe(540, 1500, 540, 700, 0.4).catch(() => {}); + await new Promise((r) => setTimeout(r, 800)); + } + if (!el) throw new Error('找不到灯卡片'); + const rect = await d.getElementRect(el); + console.log(`卡片文本 rect=(${rect.x},${rect.y}) ${rect.width}x${rect.height}`); + await d.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); + await new Promise((r) => setTimeout(r, 2500)); + + const size = await d.getWindowSize(); + console.log(`屏幕 ${size.width}x${size.height}`); + const src = await d.getSource(); + const texts = [...new Set(Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t.trim()))]; + console.log('TEXTS=' + JSON.stringify(texts)); + + // clickable 节点 + const clk = Array.from(src.matchAll(/clickable="true"[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"/g)).map((m) => m[1]); + console.log('CLICKABLE=' + JSON.stringify(clk.slice(0, 30))); + // 滑条类节点(SeekBar/ProgressBar/Slider) — 找 class 含 SeekBar/Slider 或 scrollable + const sliders = Array.from(src.matchAll(/<[^>]*class="([^"]*(?:SeekBar|Slider|ProgressBar)[^"]*)"[^>]*bounds="(\[\d+,\d+\]\[\d+,\d+\])"[^>]*>/g)).map((m) => `${m[1]} ${m[2]}`); + console.log('SLIDERS=' + JSON.stringify(sliders)); + + // 浮层下半区(y>1300)所有"宽"节点(width>400):RN 自绘滑条候选 + const wide: string[] = []; + for (const m of src.matchAll(/<[^>]*class="([^"]+)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*>/g)) { + const [cls, x1, y1, x2, y2] = [m[1], +m[2], +m[3], +m[4], +m[5]]; + const w = x2 - x1, h = y2 - y1; + if (y1 > 1300 && w > 400 && h < 300) wide.push(`${cls.split('.').pop()} [${x1},${y1}][${x2},${y2}] w${w}h${h}`); + } + console.log('WIDE_NODES=' + JSON.stringify(wide.slice(0, 20))); + + // 开灯后再看滑条填充条:点电源 → dump 滑条 y 区间(1620-1850)内所有节点(找蓝色填充 View 的右边缘=亮度) + await d.tap(540, 1455); // 电源键 + await new Promise((r) => setTimeout(r, 3500)); + const src2 = await d.getSource(); + const inTrack: string[] = []; + for (const m of src2.matchAll(/<[^>]*class="([^"]+)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*>/g)) { + const [cls, x1, y1, x2, y2] = [m[1], +m[2], +m[3], +m[4], +m[5]]; + if (y1 >= 1600 && y2 <= 1860 && x2 - x1 > 30) inTrack.push(`${cls.split('.').pop()} [${x1},${y1}][${x2},${y2}] w${x2 - x1}`); + } + const st = [...new Set(Array.from(src2.matchAll(/text="([^"]+)"/g)).map((m) => m[1]))].slice(0, 8); + console.log('AFTER_ON_TEXTS=' + JSON.stringify(st)); + console.log('TRACK_NODES=' + JSON.stringify(inTrack.slice(0, 15))); + + const ss = await d.screenshot(); + fs.writeFileSync('/tmp/light_popup.png', Buffer.from(ss, 'base64')); + console.log('截图: /tmp/light_popup.png'); + } catch (e: any) { + console.error('探针失败:', e.message); + } finally { + await d.destroySession(); + } +})(); diff --git a/scripts/probe-lights.ts b/scripts/probe-lights.ts new file mode 100644 index 0000000..03fb28e --- /dev/null +++ b/scripts/probe-lights.ts @@ -0,0 +1,20 @@ +import { createDriver } from '../drivers/factory'; +(async () => { + const d = createDriver(); + await d.createSession(); + try { + await d.dismissPopupIfPresent(); await d.goBackToHomepage(); + await new Promise(r=>setTimeout(r,1000)); + const names = new Set(); + for (let i=0;i<10;i++){ + const src = await d.getSource(); + for (const m of src.matchAll(/text="([^"]+)"/g)) { + const t=m[1]; + if (/light|lamp|bulb|neon|strip|ceiling|floor|outdoor/i.test(t)) names.add(t); + } + await d.swipe(540,1500,540,700,0.4).catch(()=>{}); + await new Promise(r=>setTimeout(r,700)); + } + console.log('LIGHTS_ON_HOME='+JSON.stringify([...names])); + } finally { await d.destroySession(); } +})(); diff --git a/scripts/probe-select.ts b/scripts/probe-select.ts new file mode 100644 index 0000000..dc7311a --- /dev/null +++ b/scripts/probe-select.ts @@ -0,0 +1,15 @@ +import { createDriver } from '../drivers/factory'; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +(async () => { + const d = createDriver(); + await d.createSession(); + try { + const dev = await d.findElementRaw('-android uiautomator', 'new UiSelector().textMatches("(?i)Relay Switch 1.*")').catch(() => null); + if (dev) { await d.tapElement(dev); console.log('已选设备'); await sleep(1500); } else console.log('未找到设备项'); + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null); + if (n) { await d.tapElement(n); console.log('已点 Next'); await sleep(3000); } else console.log('未找到 Next'); + const texts = [...new Set(Array.from((await d.getSource()).matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t.trim()))]; + console.log('下一页文案=' + JSON.stringify(texts)); + } catch (e: any) { console.error('FAIL:', e.message); } + finally { await d.destroySession(); } +})(); diff --git a/scripts/probe-skip.ts b/scripts/probe-skip.ts new file mode 100644 index 0000000..5112489 --- /dev/null +++ b/scripts/probe-skip.ts @@ -0,0 +1,16 @@ +import { createDriver } from '../drivers/factory'; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +(async () => { + const d = createDriver(); + await d.createSession(); + try { + // 点 "Skip this guide" 跳过接线引导 + const skip = await d.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Skip this guide")').catch(() => null); + if (skip) { await d.tapElement(skip); console.log('已点 Skip this guide'); await sleep(2500); } + else console.log('未找到 Skip this guide'); + const src = await d.getSource(); + const texts = [...new Set(Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t.trim()))]; + console.log('跳过后页面文案=' + JSON.stringify(texts)); + } catch (e: any) { console.error('FAIL:', e.message); } + finally { await d.destroySession(); } +})(); diff --git a/scripts/probe-slider.ts b/scripts/probe-slider.ts new file mode 100644 index 0000000..a37c505 --- /dev/null +++ b/scripts/probe-slider.ts @@ -0,0 +1,55 @@ +import { createDriver } from '../drivers/factory'; + +(async () => { + const d = createDriver(); + await d.createSession(); + const KW = process.env.LIGHT_CARD || 'Strip Light 4E'; + const W = 1080, H = 2400; + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const status = async () => { + const src = await d.getSource(); + const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); + const i = texts.findIndex((t) => t.includes(KW)); + return i >= 0 ? texts[i + 1] || '' : ''; + }; + const bri = async () => { const m = (await status()).match(/(\d+)\s*%/); return m ? +m[1] : -1; }; + try { + await d.dismissPopupIfPresent(); await d.goBackToHomepage(); await sleep(800); + let el: string | null = null; + for (let i = 0; i < 10; i++) { el = await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${KW}")`).catch(() => null); if (el) break; await d.swipe(540, 1500, 540, 700, 0.4).catch(() => {}); await sleep(800); } + if (!el) throw new Error('找不到卡片'); + const r = await d.getElementRect(el); + await d.tap(r.x + r.width / 2, r.y + r.height / 2); await sleep(2500); + + // 开灯(动态电源键) + const src0 = await d.getSource(); + const pw = Array.from(src0.matchAll(/clickable="true"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g)) + .map((m) => ({ w: +m[3] - +m[1], h: +m[4] - +m[2], cx: (+m[1] + +m[3]) / 2, cy: (+m[2] + +m[4]) / 2 })) + .filter((b) => b.w >= 150 && b.w <= 320 && b.h >= 150 && b.h <= 320 && Math.abs(b.cx - W / 2) < W * 0.15 && b.cy > H * 0.4); + if (await bri() < 0 && pw.length) { await d.tap(Math.round(pw[0].cx), Math.round(pw[0].cy)); await sleep(3500); } + console.log('开灯后状态=' + JSON.stringify(await status())); + + // 列出所有滑条 track + const src = await d.getSource(); + const tracks = Array.from(src.matchAll(/class="android\.view\.View"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g)) + .map((m) => ({ x1: +m[1], y1: +m[2], x2: +m[3], y2: +m[4] })) + .filter((b) => b.x2 - b.x1 > W * 0.8 && b.y2 - b.y1 > 40 && b.y2 - b.y1 < 280 && b.y1 > H * 0.4) + .map((b) => ({ left: b.x1 + 10, right: b.x2 - 10, y: Math.round((b.y1 + b.y2) / 2) })) + .sort((a, b) => a.y - b.y); + console.log('滑条数=' + tracks.length + ' y=' + JSON.stringify(tracks.map((t) => t.y))); + + // 逐条:从中点拖到低(0.25)读%,再拖到高(0.75)读% — 找亮度条+方向 + for (const t of tracks) { + const mid = Math.round((t.left + t.right) / 2); + const loX = Math.round(t.left + 0.25 * (t.right - t.left)); + const hiX = Math.round(t.left + 0.75 * (t.right - t.left)); + // 起点尝试:先按当前亮度反算滑块位置(若读得到),否则用中点 + const cur = await bri(); const span = t.right - t.left; + const startX = cur >= 0 ? Math.round(t.left + cur / 100 * span) : mid; + await d.swipe(startX, t.y, loX, t.y, 0.7); await sleep(2000); const aLo = await bri(); + await d.swipe(loX, t.y, hiX, t.y, 0.7); await sleep(2000); const aHi = await bri(); + console.log(`track y=${t.y}: 拖低→${aLo}% 拖高→${aHi}% ${aHi > aLo && aHi > 0 ? '★亮度条(正向)' : ''}`); + } + } catch (e: any) { console.error('探针失败:', e.message); } + finally { await d.destroySession(); } +})(); diff --git a/scripts/read-home.ts b/scripts/read-home.ts new file mode 100644 index 0000000..7338d49 --- /dev/null +++ b/scripts/read-home.ts @@ -0,0 +1,23 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); +async function main() { + const d: any = createDriver(); await d.createSession(); + try { + await d.goBackToHomepage(); await sleep(1500); await d.dismissPopupIfPresent(); + const names = new Set(); + for (let i = 0; i < 8; i++) { + const src = await d.getSource(); + for (const m of src.split('<')) { + if (!/resource-id="[^"]*\/(nameText|nameTextMeter)"/.test(m)) continue; + const t = (m.match(/text="([^"]*)"/) || [])[1]; + if (t && t.trim()) names.add(t.trim()); + } + await d.scrollDown(500); await sleep(700); + } + console.log('首页设备:', [...names].join(' | ')); + } finally { await d.destroySession(); } +} +main().catch(e => { console.error('ERR', e.message); process.exit(1); }); diff --git a/scripts/reset-except.ts b/scripts/reset-except.ts new file mode 100644 index 0000000..a2a78a4 --- /dev/null +++ b/scripts/reset-except.ts @@ -0,0 +1,66 @@ +/** + * 首页长按卡片→多选→批量删除(快),保留指定设备(woan 房间:Lock 6D / Lock Pro DE / Blind Tilt 42)。 + * 每轮:长按第一个"非保留"卡进多选 → 勾选同屏其它非保留卡(跨房间会让 Delete 消失,取消该卡)→ Delete → 确认。循环到只剩保留项。 + * 安全开关:RESET_EXCEPT=1。 用法:RESET_EXCEPT=1 PLATFORM=android npx ts-node scripts/reset-except.ts + */ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; + +const KEEP = (process.env.KEEP_DEVICES || 'Lock 6D,Lock Pro DE,Blind Tilt 42').split(',').map(s => s.trim()); +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +// 首页设备卡名 + 中心坐标(nameText / nameTextMeter) +function devices(src: string): { name: string; cx: number; cy: number }[] { + const out: { name: string; cx: number; cy: number }[] = []; + for (const n of src.split('<')) { + if (!/resource-id="[^"]*\/(nameText|nameTextMeter)"/.test(n)) continue; + const t = (n.match(/text="([^"]*)"/) || [])[1]; + const b = n.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); + if (t && t.trim() && b) out.push({ name: t.trim(), cx: Math.round((+b[1] + +b[3]) / 2), cy: Math.round((+b[2] + +b[4]) / 2) }); + } + return out; +} +const isKept = (name: string) => KEEP.some(k => name === k); + +async function main() { + if (process.env.RESET_EXCEPT !== '1') { console.error('已阻止。RESET_EXCEPT=1 重跑。'); process.exit(2); } + const driver = createDriver(); + await driver.createSession(); + const d: any = driver; + const hasDelete = () => driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")'); + const exitSelect = async () => { const f = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")'); if (f) { await driver.tapElement(f); await sleep(700); } }; + try { + console.log('保留:', KEEP.join(', ')); + let total = 0; + for (let round = 0; round < 30; round++) { + await driver.goBackToHomepage(); await sleep(1800); + await driver.dismissPopupIfPresent(); + const all = devices(await driver.getSource()); + const nonKept = all.filter(x => !isKept(x.name)); + if (!nonKept.length) { console.log('只剩保留项,结束'); break; } + // 长按第一个非保留卡进多选 + const first = nonKept[0]; + await d.longPress(first.cx, first.cy, 1.0); await sleep(1000); + if (!(await hasDelete())) { await exitSelect(); console.log(`长按 ${first.name} 未进多选,跳过本轮`); continue; } + let batch = [first.name]; + // 勾选同屏其它非保留卡(重新读坐标,避免多选模式下偏移) + for (const x of devices(await driver.getSource()).filter(o => !isKept(o.name) && o.name !== first.name)) { + await driver.tap(x.cx, x.cy); await sleep(400); + if (await hasDelete()) { batch.push(x.name); } + else { await driver.tap(x.cx, x.cy); await sleep(300); } // 跨房间→取消该卡 + } + const del = await hasDelete(); + if (!del) { await exitSelect(); continue; } + await driver.tapElement(del); await sleep(900); + for (let r = 0; r < 4; r++) { const s = await driver.getSource(); if (/Cancel|取消|delete|删除/i.test(s)) { let c: string | null = null; for (const t of ['Delete', 'OK', '删除', 'Confirm']) { c = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`); if (c) break; } if (c) { await driver.tapElement(c); break; } } await sleep(500); } + await sleep(2000); + total += batch.length; + console.log(`本批删 ${batch.length}: ${batch.join(', ')}`); + } + await driver.goBackToHomepage(); await sleep(1500); + console.log(`\n共删 ${total}。剩余:`, devices(await driver.getSource()).map(x => x.name).join(', ') || '(无)'); + } finally { await driver.destroySession(); } +} +main().catch(e => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/reset-off.ts b/scripts/reset-off.ts new file mode 100644 index 0000000..1aa8188 --- /dev/null +++ b/scripts/reset-off.ts @@ -0,0 +1,61 @@ +/** + * 复位:把首页所有 On/Off 开关类设备(Plug Mini/彩灯/吸顶灯/Bot 等显示 "On" 的卡)关成 Off。 + * 手动滚动找卡 + 卡名右上开关钮坐标点击(同 plug 控制);靠顶卡先下滑避免误触顶栏。 + * 用法:PLATFORM=android npx ts-node scripts/reset-off.ts + */ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +// 解析首页卡片:卡名(nameText)+ 同列下方的 On/Off 状态 + 开关钮坐标。 +function onCards(src: string): { name: string; nameX: number; nameY: number; nameW: number }[] { + const names: { name: string; x: number; y: number; w: number }[] = []; + const states: { t: string; x: number; y: number }[] = []; + for (const n of src.split('<')) { + const b = n.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); + if (/resource-id="[^"]*\/nameText"/.test(n)) { + const t = (n.match(/text="([^"]*)"/) || [])[1]; + if (t && t.trim() && b) names.push({ name: t.trim(), x: +b[1], y: +b[2], w: +b[3] - +b[1] }); + } + const st = (n.match(/text="(On|Off)"/) || [])[1]; + if (st && b) states.push({ t: st, x: +b[1], y: +b[2] }); + } + // 卡名匹配同列下方(x±200, y在[nameY,nameY+150])的 On 状态 + const out: { name: string; nameX: number; nameY: number; nameW: number }[] = []; + for (const nm of names) { + const s = states.find(s => Math.abs(s.x - nm.x) < 200 && s.y > nm.y && s.y < nm.y + 150); + if (s && s.t === 'On') out.push({ name: nm.name, nameX: nm.x, nameY: nm.y, nameW: nm.w }); + } + return out; +} + +async function main() { + const d: any = createDriver(); + await d.createSession(); + try { + let total = 0; + for (let round = 0; round < 15; round++) { + await d.goBackToHomepage(); await sleep(1200); await d.dismissPopupIfPresent(); + // 滚动找一个 On 卡 + let target: { name: string; nameX: number; nameY: number; nameW: number } | null = null; + for (let i = 0; i < 8 && !target; i++) { + const c = onCards(await d.getSource()); + if (c.length) target = c[0]; + else { await d.scrollDown(500); await sleep(700); } + } + if (!target) { console.log('首页已无 On 设备,完成'); break; } + // 靠顶卡:开关钮 y-95 会落顶栏 → 下滑再读 + if (target.nameY - 95 < 300) { await d.scrollDown(400); await sleep(800); const c2 = onCards(await d.getSource()).find(x => x.name === target!.name); if (c2) target = c2; } + const btnX = target.nameX + target.nameW - 48, btnY = target.nameY - 95; + await d.tap(btnX, btnY); await sleep(2500); + await d.dismissPopupIfPresent(); + total++; + console.log(`关闭 ${target.name} (开关钮 ${btnX},${btnY})`); + await sleep(1500); + } + console.log(`\n共关闭 ${total} 个 On/Off 开关设备`); + } finally { await d.destroySession(); } +} +main().catch(e => { console.error('ERR', e.message); process.exit(1); }); diff --git a/scripts/run-ios-adds.sh b/scripts/run-ios-adds.sh new file mode 100644 index 0000000..04a9b90 --- /dev/null +++ b/scripts/run-ios-adds.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# iOS 添加用例批量调试:每个用例前用 WDA terminate/launch 复位到首页,跑完记录结果。 +# WDA 须已在 localhost:8100。失败不阻塞,继续下一个。 +set -u +cd "$(dirname "$0")/.." +export PLATFORM=ios SOAK_MODE=1 +BUNDLE=com.wohand.wohand +LOG=reports/ios-adds.log +: > "$LOG" + +reset_home(){ + local sid + sid=$(curl -s --max-time 5 http://localhost:8100/status 2>/dev/null | grep -oE '"sessionId" *: *"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/') + [ -z "$sid" ] && { echo "[reset] 无 WDA session(WDA 掉了?)" | tee -a "$LOG"; return 1; } + curl -s -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 -X POST "http://localhost:8100/session/$sid/wda/apps/launch" -H 'Content-Type: application/json' -d "{\"bundleId\":\"$BUNDLE\"}" >/dev/null 2>&1 + sleep 3 +} + +# BLE 类添加(不依赖 WiFi 配网),先跑这些 +ADDS=( + "meter_plus|npx vitest run tests/meter/meter_plus_connect.test.ts" + "remote|npx vitest run tests/remote/remote_connect.test.ts" + "motion_sensor|npx vitest run tests/sensor/motion_sensor_connect.test.ts" + "contact_sensor|npx vitest run tests/sensor/contact_sensor_connect.test.ts" + "find_card|npx vitest run tests/find_card/find_card_connect.test.ts" + "outdoor_meter|npx vitest run tests/meter/outdoor_meter_connect.test.ts" + "meter_pro|npx vitest run tests/meter/meter_pro_connect.test.ts" + "presence|npx vitest run tests/sensor/presence_sensor_connect.test.ts" + "safety_alarm|npx vitest run tests/safety_alarm/safety_alarm_connect.test.ts" + "curtain|npx vitest run tests/curtain/curtain_connect.test.ts" + "curtain3|CURTAIN_DEVICE='Curtain3 2B' npx vitest run tests/curtain/curtain_connect.test.ts" + "curtain3_2025|npx vitest run tests/curtain/curtain3_2025_connect.test.ts" + "keypad_touch|npx vitest run tests/keypad/keypad_touch_connect.test.ts" + "keypad_vision|npx vitest run tests/keypad/keypad_vision_connect.test.ts" +) + +echo "===== $(date '+%F %T') iOS 添加批量开始 =====" | tee -a "$LOG" +for entry in "${ADDS[@]}"; do + name="${entry%%|}"; name="${entry%%|*}"; cmd="${entry#*|}" + echo "----- $(date +%T) [$name] -----" | tee -a "$LOG" + reset_home + eval "$cmd" > /tmp/ios-add-one.log 2>&1 + grep -E '✓ \[PASS\]|✗ \[FAIL\]|○ \[SKIP\]|结果:|relay:|未触发|not found|cannot|品类' /tmp/ios-add-one.log | grep -ivE "deprecat" | tail -6 | tee -a "$LOG" +done +echo "===== $(date '+%F %T') 完成 =====" | tee -a "$LOG" diff --git a/scripts/run-p0-all.sh b/scripts/run-p0-all.sh new file mode 100755 index 0000000..7750dcb --- /dev/null +++ b/scripts/run-p0-all.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# 跑所有已调通的 P0 必测用例,累积出一份合并报告。 +# 规则:ADD 阶段跑完后,失败的添加在进 CONTROL 前再重试最多 2 次;2 次仍失败则放弃、继续后续用例。 +# SOAK_MODE=1:跨多次 vitest run 累积到 reports/.results.json,末尾 gen-combined 出报告。 +set -u +cd /Users/woan/Desktop/AI_UIAutomation +LOG=reports/p0-all.log +export PLATFORM=android SOAK_MODE=1 SKIP_PROTO_NET=1 +export LOCK_DEVICE="Lock 6D" LOCK_PRO_DEVICE="Lock Pro DE" PLUG_CTRL_DEVICE="Plug Mini" BT_DEVICE="Blind Tilt 42" + +rm -f reports/.results.json + +# 添加命令(含各设备专属 env);灯类/EU plug 用品类入口区分 +ADD_CMDS=( + "npx vitest run tests/bot/bot_connect.test.ts" + "npx vitest run tests/meter/meter_connect.test.ts" + "npx vitest run tests/meter/meter_plus_connect.test.ts" + "npx vitest run tests/meter/outdoor_meter_connect.test.ts" + "npx vitest run tests/meter/meter_pro_connect.test.ts" + "npx vitest run tests/sensor/motion_sensor_connect.test.ts" + "npx vitest run tests/sensor/presence_sensor_connect.test.ts" + "npx vitest run tests/remote/remote_connect.test.ts" + "npx vitest run tests/curtain/curtain_connect.test.ts" + "CURTAIN_DEVICE='Curtain3 2B' npx vitest run tests/curtain/curtain_connect.test.ts" + "npx vitest run tests/curtain/curtain3_2025_connect.test.ts" + "npx vitest run tests/water_detector/water_detector_connect.test.ts" + "npx vitest run tests/hub/hub_matter_connect.test.ts" + "npx vitest run tests/find_card/find_card_connect.test.ts" + "npx vitest run tests/safety_alarm/safety_alarm_connect.test.ts" + "PLUG_MINI_CATEGORY='Plug Mini (JP)' PLUG_MINI_RELAY=plug_mini npx vitest run tests/plug/plug_mini_connect.test.ts" + "PLUG_MINI_CATEGORY='Plug Mini (US)' PLUG_MINI_RELAY=plug_mini_us npx vitest run tests/plug/plug_mini_connect.test.ts" + "PLUG_MINI_CATEGORY='Plug Mini (EU)' PLUG_MINI_RELAY=plug_mini_eu PLUG_MINI_ONES=332918 npx vitest run tests/plug/plug_mini_connect.test.ts" + "npx vitest run tests/color_bulb/color_bulb_connect.test.ts" + "npx vitest run tests/ceiling_light/ceiling_light_pro_connect.test.ts" + "npx vitest run tests/strip_light/strip_light_connect.test.ts" + "npx vitest run tests/ceiling_light/floor_lamp_connect.test.ts" + "npx vitest run tests/ceiling_light/color_floor_lamp_connect.test.ts" + "npx vitest run tests/strip_light/neon_light_connect.test.ts" + "npx vitest run tests/keypad/keypad_vision_connect.test.ts" + "npx vitest run tests/keypad/keypad_touch_connect.test.ts" + "npx vitest run tests/aihub/aihub_connect.test.ts -t '通过BLE添加'" + "npx vitest run tests/air_purifier/air_purifier_connect.test.ts" + "npx vitest run tests/humidifier/humidifier2_connect.test.ts" + "npx vitest run tests/ceiling_light/eave_light_connect.test.ts" + "npx vitest run tests/urc/urc_connect.test.ts" + "npx vitest run tests/hub/relay_switch_connect.test.ts" + "npx vitest run tests/hub/hub3_connect.test.ts" + "npx vitest run tests/aihub/ai_art_frame_connect.test.ts" + "npx vitest run tests/sensor/contact_sensor_connect.test.ts" + "npx vitest run tests/camera/doorbell_connect.test.ts" +) +CONTROL=( + "npx vitest run tests/meter/meter_control.test.ts" + "npx vitest run tests/bot/bot_card.test.ts" + "npx vitest run tests/lock/lock_card.test.ts" + "npx vitest run tests/curtain/curtain_control.test.ts" + "npx vitest run tests/plug/plug_mini_control.test.ts" + "npx vitest run tests/curtain/blind_tilt_control.test.ts" + "npx vitest run tests/humidifier/humidifier2_card.test.ts" + "npx vitest run tests/air_purifier/air_purifier_card.test.ts" + "npx vitest run tests/ceiling_light/eave_light_control.test.ts" +) +TAIL=( + "STREAM_HOLD_MS=60000 npx vitest run tests/camera/camera_stream.test.ts" + "npx vitest run tests/platform/feedback.test.ts" + "npx vitest run tests/platform/rn_plugin.test.ts" + "npx vitest run tests/platform/create_room.test.ts" + "npx vitest run tests/platform/message_center.test.ts" + "npx vitest run tests/platform/scene_operation.test.ts" + "npx vitest run tests/auth/third_party_login.test.ts" + "npx vitest run tests/auth/eu_login.test.ts" + "npx vitest run tests/auth/register.test.ts" + "npx vitest run tests/auth/forgot_password.test.ts" + "npx vitest run tests/auth/delete_account.test.ts" + "npx vitest run tests/auth/login.test.ts" +) + +# 跑一条命令,过滤出结果行;含 "✗ [FAIL]" 视为失败(返回1),否则0(PASS/SKIP) +run_one() { + local cmd="$1" tmp + tmp=$(mktemp) + eval "$cmd" > "$tmp" 2>&1 + grep -E '✓ \[PASS\]|✗ \[FAIL\]|○ \[SKIP\]|结果:' "$tmp" | tail -12 + if grep -q '✗ \[FAIL\]' "$tmp"; then rm -f "$tmp"; return 1; fi + rm -f "$tmp"; return 0 +} + +# 恢复手机 WiFi(BLE 控制阶段关了 WiFi)→ 打开并验证 wlan0 拿到 IP(联网成功),最多重试 5 次。 +restore_wifi() { + for i in 1 2 3 4 5; do + adb shell svc wifi enable >/dev/null 2>&1 + sleep 8 + if adb shell ip addr show wlan0 2>/dev/null | grep -q "inet "; then + echo "[WiFi] 已恢复联网(第${i}次)"; return 0 + fi + echo "[WiFi] 未联网,重试 ${i}/5..."; sleep 5 + done + echo "[WiFi] WARN: 多次重试仍未恢复 WiFi,后续 WiFi 控制/平台/账号用例可能失败" + return 1 +} + +{ +echo "===== $(date '+%F %T') P0 全量回归开始 =====" + +echo "----- 复位:跳过(用户已手动删除全部待添加设备;摄像头/门铃等保留不动)-----" +# 注:摄像头(Indoor Cam/Pan-Tilt/Outdoor Spotlight Cam/Video Doorbell)拉流用例需要,绝不可删。 +# RESET_EXCEPT=1 KEEP_DEVICES="Lock 6D,Lock Pro DE,Blind Tilt 42" npx ts-node scripts/reset-except.ts 2>&1 | tail -5 || echo "复位异常(继续跑)" + +echo "----- ADD 阶段 -----" +FAILED=() +for cmd in "${ADD_CMDS[@]}"; do + echo "--- $(date +%T) $cmd ---" + run_one "$cmd" || { echo "[ADD-FAIL] $cmd"; FAILED+=("$cmd"); } +done + +# 规则:失败的添加,进 CONTROL 前重试最多 2 次;仍失败则放弃 +if [ ${#FAILED[@]} -gt 0 ]; then + echo "===== 添加失败 ${#FAILED[@]} 个,重试(每个最多2次)=====" + STILL=() + for cmd in "${FAILED[@]}"; do + ok=0 + for att in 1 2; do + echo "--- 重试 $att/2: $cmd ---" + if run_one "$cmd"; then ok=1; echo "[RETRY-OK] $cmd"; break; fi + done + [ $ok -eq 0 ] && { echo "[RETRY-GIVEUP] $cmd"; STILL+=("$cmd"); } + done + echo "重试后仍失败 ${#STILL[@]} 个(已放弃,继续后续用例)" +fi + +echo "----- CONTROL 阶段(BLE + WiFi 各跑一遍,分别打 15975/15974 锚点)-----" +# BLE 阶段:关手机 WiFi 强制 BLE 直连控制 +adb shell svc bluetooth enable >/dev/null 2>&1 +adb shell svc wifi disable >/dev/null 2>&1; sleep 5 +export PROTO=ble +echo "===== 控制 PROTO=ble(手机 WiFi 已关)=====" +for cmd in "${CONTROL[@]}"; do echo "--- $(date +%T) [ble] $cmd ---"; run_one "$cmd" || true; done + +# 关键:BLE 控制完成后恢复手机 WiFi(带重试)。没 WiFi 后续 WiFi 控制+平台+账号都会挂。 +restore_wifi + +export PROTO=wifi +echo "===== 控制 PROTO=wifi(手机 WiFi 已恢复)=====" +for cmd in "${CONTROL[@]}"; do echo "--- $(date +%T) [wifi] $cmd ---"; run_one "$cmd" || true; done +unset PROTO +restore_wifi # 进平台/账号前再确保 WiFi 在线 + +echo "----- 平台 + 账号阶段 -----" +for cmd in "${TAIL[@]}"; do echo "--- $(date +%T) $cmd ---"; run_one "$cmd" || true; done + +echo "----- 生成合并报告 -----" +npx ts-node scripts/gen-combined.ts P0_All_Android +echo "===== $(date '+%F %T') 完成 =====" +} >> "$LOG" 2>&1 diff --git a/scripts/start-wda.sh b/scripts/start-wda.sh index 8144038..9db9763 100755 --- a/scripts/start-wda.sh +++ b/scripts/start-wda.sh @@ -1,6 +1,6 @@ #!/bin/bash # Start WDA on device and ensure iproxy is running -UDID="00008110-001A34303AE9801E" +UDID="${WDA_UDID:-$(idevice_id -l 2>/dev/null | head -1)}" WDA_PORT=8100 WDA_PROJECT="/Users/woan/.appium/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent" diff --git a/tests/_pm_continue.test.ts b/tests/_pm_continue.test.ts new file mode 100644 index 0000000..64f1c1e --- /dev/null +++ b/tests/_pm_continue.test.ts @@ -0,0 +1,27 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { DeviceDriver } from '../drivers/types'; +import { createDriver } from '../drivers/factory'; +import { enterPairingMode, configureHubWifi, sleep } from '../utils/common'; +import { getWifiCredentials } from '../config/wifi.config'; +const texts = (s: string) => (s.match(/text="[^"]*"/g) || []).filter(x => !/text=""/.test(x)).join(' | '); +describe('continue plugmini', () => { + let driver: DeviceDriver; + beforeAll(async () => { driver = createDriver(); await driver.createSession(); }); + afterAll(async () => { await driver.destroySession(); }); + it('from guide', { timeout: 120000 }, async () => { + console.log('CURRENT::', texts(await driver.getSource()).slice(0,200)); + // 引导页:按 ch1 长按 + Next 触发连接 + await enterPairingMode('plug_mini').catch(e=>console.log('relay err', e.message)); + await sleep(1500); + const n = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (n) { await driver.tapElement(n); console.log('tapped Next'); } + // 等连接 + 看是否到 WiFi 配网页 + for (let i=0;i<8;i++){ await sleep(3000); const s=await driver.getSource(); if(/Configure Wi-?Fi|Wi-?Fi password/i.test(s)){console.log('WIFI_PAGE@'+i+'::', texts(s)); break;} console.log('WAIT'+i+'::', texts(s).slice(0,150)); } + // 配网 + await configureHubWifi(driver, getWifiCredentials()); + await sleep(3000); + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); await sleep(2000); + console.log('HOME_PLUG::', texts(await driver.getSource()).split('|').filter(t=>/Plug/i.test(t)).join(' ')); + }); +}); diff --git a/tests/aihub/ai_art_frame_connect.test.ts b/tests/aihub/ai_art_frame_connect.test.ts new file mode 100644 index 0000000..e1e671c --- /dev/null +++ b/tests/aihub/ai_art_frame_connect.test.ts @@ -0,0 +1,137 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + isDeviceOnHomepage, + enterPairingMode, + configureHubWifi, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// AI Art Frame:**流程同 AI Hub**(relay ch46,长按 3s 进添加模式)。入口品类「AI Art Frame」。 +// 选品类 → 长按 ch46 3s → Connect Device → 同意隐私 → 等发现(上电约1分钟) → WiFi 配网。 +// 入口名/首页名 env 可覆盖。ONES 添加号待补,先 [P0]。 +const FRAME_NAME = process.env.AI_ART_FRAME_NAME || 'AI Art Frame'; +const CATEGORY = process.env.AI_ART_FRAME_CATEGORY || 'AI Art Frame'; +// 首页/校验关键字:首页设备名实测渲染为 "Al Art Frame C6"(AI→Al,小写L),用无歧义的 "Art Frame" 匹配(AI/Al 通吃且唯一) +const HOME_KW = process.env.AI_ART_FRAME_HOME || 'Art Frame'; +const ADD_ANCHOR = '[P0]'; // TODO: 待补 AI Art Frame 添加 ONES 编号 + +describe('AIArtFrame Connect - 添加AI Art Frame设备', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('AIArtFrame_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加AI Art Frame`, { timeout: 360000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const alreadyExists = await isDeviceOnHomepage(driver, HOME_KW); + if (alreadyExists) { + console.log(`${FRAME_NAME}已在首页,跳过重新添加`); + reporter.record(`${ADD_ANCHOR} 添加${FRAME_NAME}`, 'SKIP', Date.now() - start, `${FRAME_NAME}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: HOME_KW, + scanTimeout: 30000, + skipScanStep: true, + skipNextButton: true, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Initial Setup', 'Start Using', 'Done', 'added successfully', 'Added successfully', + 'Got it', 'cloud service', 'Pick a room', 'Display Type', 'Select a room', + 'Wi-Fi', 'WiFi', 'SSID', 'Network', 'Configure Wi-Fi', 'Connecting', 'Start now', + ], + preSelectSteps: async (d: DeviceDriver) => { + // 选品类后可能有 Agree/免责页 + const agreeEl = await d.findElementRaw('name', 'Agree').catch(() => null); + if (agreeEl) { await d.clickElement(agreeEl); await sleep(2000); } + + // 继电器 ch46 长按 3s 进添加模式。AI_ART_FRAME_NO_RELAY=1:设备已手动进配对则跳过。 + if (!process.env.AI_ART_FRAME_NO_RELAY) { + await enterPairingMode('ai_art_frame').catch((e) => console.log(`relay ch46 长按失败: ${e.message}`)); + console.log('AI Art Frame: 已长按继电器 3s,等待设备启动...'); + await sleep(5000); + } else { + console.log('AI Art Frame: 跳过继电器(设备已手动进配对)'); + } + + // 点 Connect Device 开始搜索 + for (const t of ['Connect device', 'Connect Device', 'Connect devices', 'Connect Devices']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { console.log(`AI Art Frame: 点击 "${t}" 开始搜索`); await d.clickElement(b); await sleep(2000); break; } + } + // 隐私协议(可能先勾选框再点 Agree) + await sleep(1500); + { + const cb = await d.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.CheckBox")').catch(() => null); + if (cb) { await d.clickElement(cb).catch(() => {}); await sleep(500); } + for (const t of ['Agree', 'Agree and Continue', 'I Agree', 'Accept', 'Continue', '同意', '同意并继续']) { + const a = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null) + || await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`).catch(() => null); + if (a) { console.log(`AI Art Frame: 同意隐私协议 "${t}"`); await d.clickElement(a); await sleep(2500); break; } + } + } + // 等发现设备(上电后约1分钟才出现,最长 ~100s) + console.log('AI Art Frame: 等待发现设备...'); + for (let i = 0; i < 33; i++) { + await sleep(3000); + const s = await d.getSource().catch(() => ''); + if (/Configure Wi-?Fi|Wi-?Fi Settings|SSID|Network name|Pick a room|Select a room|Setting up|Connecting to your router|Verifying|Name your|added successfully|Start now|Select Device/i.test(s)) { + console.log('AI Art Frame: 已进入配网/后续页'); break; + } + } + }, + postConnectionSteps: async (d: DeviceDriver) => { + // 若有 Select Device 列表,先选中再 Next(兼容,无则直接配网) + const src = await d.getSource(); + if (/Select Device/i.test(src)) { + const dev = await d.findElementRaw('-android uiautomator', `new UiSelector().textMatches("(?i).*Art Frame.*")`).catch(() => null); + if (dev) { await d.tapElement(dev); await sleep(1500); } + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null); + if (n) { await d.tapElement(n); await sleep(3500); } + } + await configureHubWifi(d, wifi); + // 配网后进功能页可能弹"固件升级"框遮挡首页校验 → 关掉(Later/Not Now/稍后/Skip 等) + await sleep(2000); + for (let i = 0; i < 2; i++) { + let dismissed = false; + for (const t of ['Later', 'Not Now', 'Not now', 'Update Later', 'Skip', 'Cancel', 'Maybe Later', '稍后', '以后再说', '暂不升级', '取消', '忽略']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`).catch(() => null); + if (b) { await d.tapElement(b).catch(() => {}); console.log(`AI Art Frame: 关闭升级弹框 "${t}"`); await sleep(1500); dismissed = true; break; } + } + if (!dismissed) break; + } + }, + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加${FRAME_NAME}`, 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加${FRAME_NAME}`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/aihub/aihub_connect.test.ts b/tests/aihub/aihub_connect.test.ts index 7e1cb2d..bc355a6 100644 --- a/tests/aihub/aihub_connect.test.ts +++ b/tests/aihub/aihub_connect.test.ts @@ -6,15 +6,16 @@ import { sleep, addDeviceViaBLE, isDeviceOnHomepage, + enterPairingMode, + configureHubWifi, } from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); const AIHUB_NAME = process.env.AIHUB_NAME || 'AI Hub 6C'; -const WIFI_SSID = process.env.WIFI_SSID || 'SwitchBot_Office'; -const WIFI_PASSWORD = process.env.WIFI_PASSWORD || ''; describe('AIHub Connect - 添加AI Hub设备', () => { let driver: DeviceDriver; @@ -31,103 +32,68 @@ describe('AIHub Connect - 添加AI Hub设备', () => { await driver.destroySession(); }); - async function inputWiFiCredentials(driver: DeviceDriver): Promise { - await sleep(3000); - const source = await driver.getSource(); - - if (!source.includes('Wi-Fi') && !source.includes('WiFi') && !source.includes('SSID') && !source.includes('Network')) { - return; - } - - if (driver.platform === 'ios') { - const ssidField = await driver.findElementRaw('predicate string', - 'type == "XCUIElementTypeTextField" AND (value CONTAINS "SSID" OR value CONTAINS "Wi-Fi" OR value CONTAINS "Network" OR value == "")'); - if (ssidField) { - await driver.tapElement(ssidField); - await sleep(500); - await driver.clearText(ssidField); - await driver.typeText(ssidField, WIFI_SSID); - await sleep(500); - } - - const pwdField = await driver.findElementRaw('predicate string', - 'type == "XCUIElementTypeSecureTextField" OR (type == "XCUIElementTypeTextField" AND value CONTAINS "Password")'); - if (pwdField) { - await driver.tapElement(pwdField); - await sleep(500); - await driver.clearText(pwdField); - await driver.typeText(pwdField, WIFI_PASSWORD); - await sleep(500); - } - } else { - const ssidField = await driver.findElementRaw('-android uiautomator', - 'new UiSelector().className("android.widget.EditText").instance(0)'); - if (ssidField) { - await driver.tapElement(ssidField); - await sleep(300); - await driver.clearText(ssidField); - await driver.typeText(ssidField, WIFI_SSID); - await sleep(500); - } - - const pwdField = await driver.findElementRaw('-android uiautomator', - 'new UiSelector().className("android.widget.EditText").instance(1)'); - if (pwdField) { - await driver.tapElement(pwdField); - await sleep(300); - await driver.clearText(pwdField); - await driver.typeText(pwdField, WIFI_PASSWORD); - await sleep(500); - } - } - - const nextBtnNames = ['Next', 'Connect', 'Join', 'OK', 'Confirm']; - for (const btn of nextBtnNames) { - let el: string | null = null; - if (driver.platform === 'ios') { - el = await driver.findElementRaw('name', btn); - } else { - el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${btn}")`); - } - if (el) { - await driver.tapElement(el); - await sleep(5000); - break; - } - } - } - function getAIHubAddOptions() { + const wifi = getWifiCredentials(); return { categoryName: 'AI Hub', - deviceKeyword: AIHUB_NAME, + deviceKeyword: 'AI Hub', scanTimeout: 30000, skipScanStep: true, skipNextButton: true, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], connectionKeywords: [ - 'Initial Setup', 'Start Using', 'Done', 'added successfully', - 'Got it', 'cloud service', 'Pick a room', 'Display Type', - 'Wi-Fi', 'WiFi', 'SSID', 'Network', 'Configure Wi-Fi', + 'Initial Setup', 'Start Using', 'Done', 'added successfully', 'Added successfully', + 'Got it', 'cloud service', 'Pick a room', 'Display Type', 'Select a room', + 'Wi-Fi', 'WiFi', 'SSID', 'Network', 'Configure Wi-Fi', 'Connecting', 'Start now', ], preSelectSteps: async (d: DeviceDriver) => { - const el = await d.findElementRaw('name', 'Connect Device'); - if (el) { - await d.clickElement(el); - await sleep(2000); - } + // 选品类后可能有 Agree/免责页,先过掉 const agreeEl = await d.findElementRaw('name', 'Agree'); - if (agreeEl) { - await d.clickElement(agreeEl); + if (agreeEl) { await d.clickElement(agreeEl); await sleep(2000); } + + // 继电器 ch43 控制供电 Plug 的开/关状态,**短按一下即可**(holdMs 400,结束释放不保持)。 + // AIHUB_NO_RELAY=1:设备已手动进配对,跳过继电器。 + if (!process.env.AIHUB_NO_RELAY) { + await enterPairingMode('aihub').catch((e) => console.log(`relay ch43 短按失败: ${e.message}`)); + console.log('AI Hub: 已短按继电器(切 Plug),等待设备启动...'); + await sleep(5000); + } else { + console.log('AI Hub: 跳过继电器(设备已手动进配对)'); + } + + // 点 "Connect Device"(链接设备)开始搜索 + for (const t of ['Connect device', 'Connect Device', 'Connect devices', 'Connect Devices']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { console.log(`AI Hub: 点击 "${t}" 开始搜索`); await d.clickElement(b); await sleep(2000); break; } + } + // 点连接设备后会弹隐私协议 → 同意(可能先勾选框再点 Agree) + await sleep(1500); + { + const cb = await d.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.CheckBox")').catch(() => null); + if (cb) { await d.clickElement(cb).catch(() => {}); await sleep(500); } + for (const t of ['Agree', 'Agree and Continue', 'I Agree', 'Accept', 'Continue', '同意', '同意并继续']) { + const a = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null) + || await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`).catch(() => null); + if (a) { console.log(`AI Hub: 同意隐私协议 "${t}"`); await d.clickElement(a); await sleep(2500); break; } + } + } + // 等待发现设备 → 进配网(上电后约 1 分钟才出现,最长等 ~100s) + console.log('AI Hub: 等待发现设备...'); + for (let i = 0; i < 33; i++) { await sleep(3000); + const s = await d.getSource().catch(() => ''); + if (/Configure Wi-?Fi|Wi-?Fi Settings|SSID|Network name|Pick a room|Select a room|Setting up|Connecting to your router|Verifying|Name your|added successfully|Start now/i.test(s)) { + console.log('AI Hub: 已进入配网/后续页'); break; + } } }, postConnectionSteps: async (d: DeviceDriver) => { - await inputWiFiCredentials(d); + await configureHubWifi(d, wifi); }, }; } - it('通过BLE添加AI Hub 6C设备', { timeout: 180000 }, async () => { + it('通过BLE添加AI Hub 6C设备', { timeout: 360000 }, async () => { const start = Date.now(); try { const alreadyExists = await isDeviceOnHomepage(driver, AIHUB_NAME); @@ -149,8 +115,8 @@ describe('AIHub Connect - 添加AI Hub设备', () => { } }); - const SETTINGS_ICON = driver.platform === 'android' ? { x: 999, y: 175 } : { x: 361, y: 70 }; - const BACK_BTN = driver.platform === 'android' ? { x: 50, y: 88 } : { x: 39, y: 70 }; + const SETTINGS_ICON = (process.env.PLATFORM || 'android').toLowerCase() !== 'ios' ? { x: 999, y: 175 } : { x: 361, y: 70 }; + const BACK_BTN = (process.env.PLATFORM || 'android').toLowerCase() !== 'ios' ? { x: 50, y: 88 } : { x: 39, y: 70 }; async function navigateToHubFunctionPage(): Promise { await driver.goBackToHomepage(); diff --git a/tests/air_purifier/air_purifier_card.test.ts b/tests/air_purifier/air_purifier_card.test.ts new file mode 100644 index 0000000..b3ee1a2 --- /dev/null +++ b/tests/air_purifier/air_purifier_card.test.ts @@ -0,0 +1,161 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { sleep, waitForSource } from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Air Purifier(口头"空净",WiFi):控制必测=**首页卡片快捷控制浮层**(非功能页)。流程同加湿器2(Evaporative Humidifier)。 +// 点首页卡片 → 弹浮层:顶部状态字(Off/运行) + **无文案电源圆键** + 模式行(Level/Auto/Pet/Sleep) + More。 +// 实测 1080x2400:电源键中心 ≈ (540, 1247)=屏中线、h*0.52(浮层顶 y≈911,加湿器2 的 h*0.36 落在浮层外故不通用)。 +// 模式键有文字,按 text 定位即可;切模式需先开机。ONES 控制号待补,先 [P0] 占位。 +const CARD_KEYWORD = process.env.AIR_PURIFIER_CARD || 'Air Purifier'; +const MODES = ['Level', 'Auto', 'Pet', 'Sleep']; // 实测浮层模式集合 +const ADD_ANCHOR = '[P0]'; // TODO: 待补 Air Purifier 控制 ONES 编号 + +describe('AirPurifier Card - 首页卡片控制(Air Purifier)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('AirPurifier_Card', driver.platform.toUpperCase()); + }); + + beforeEach(async () => { + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); + await driver.dismissPopupIfPresent(); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + async function findCard(): Promise { + for (let r = 0; r < 8; r++) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null); + if (el) return el; + await driver.swipe(540, 1400, 540, 600, 0.4).catch(() => {}); + await sleep(900); + } + throw new Error(`找不到${CARD_KEYWORD}卡片`); + } + + // 点卡片中心 → 弹快捷控制浮层 + async function openCardPopup(): Promise { + const cardId = await findCard(); + const rect = await driver.getElementRect(cardId); + await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); + await sleep(2000); + const ok = await waitForSource(driver, 'More', 6000); + if (!ok) throw new Error('卡片快捷控制浮层未弹出'); + } + + // 无文案电源圆键:浮层是固定底部弹层,电源键在屏中线、状态字下方/模式行上方。 + // 实测 1080x2400 电源键中心 (540, 1247) ≈ (w/2, h*0.52)。 + async function tapPower(): Promise { + let w = 1080, h = 2400; + try { const s: any = await (driver as any).getWindowSize?.(); if (s?.width) { w = s.width; h = s.height; } } catch { /* 默认 1080x2400 */ } + const px = Math.round(w / 2); + const py = Math.round(h * 0.52); + console.log(`点电源键 (${px}, ${py})`); + await driver.tap(px, py); + } + + // 当前是否关机:取首个含设备名(首页卡)后的状态字 —— 首页卡副标题镜像设备态:关=Off,开=模式/数值。 + async function isOff(): Promise { + const src = await driver.getSource(); + const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); + const i = texts.findIndex((t) => t.includes(CARD_KEYWORD)); + const status = i >= 0 ? (texts[i + 1] || '') : ''; + console.log(`状态字="${status}"`); + return /^Off$|Offline|待机/.test(status); + } + + async function pollState(wantOff: boolean, ms = 15000): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if ((await isOff()) === wantOff) return true; + await sleep(1500); + } + return (await isOff()) === wantOff; + } + + it(`${ADD_ANCHOR} 首页找到空净卡片`, async () => { + const start = Date.now(); + try { + const cardId = await findCard(); + const rect = await driver.getElementRect(cardId); + const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; + console.log(`${CARD_KEYWORD} ${detail}`); + expect(rect.width).toBeGreaterThan(0); + reporter.record(`${ADD_ANCHOR} 首页找到空净卡片`, 'PASS', Date.now() - start, detail); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页找到空净卡片`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片开机`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + if (!(await isOff())) { await tapPower(); await pollState(true); } + await tapPower(); + const on = await pollState(false); + console.log(`开机后 isOff=${!on}`); + expect(on).toBe(true); + reporter.record(`${ADD_ANCHOR} 首页卡片开机`, 'PASS', Date.now() - start, '卡片浮层电源键开机成功(状态→运行)'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片开机`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片关机`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + if (await isOff()) { await tapPower(); await pollState(false); } + await tapPower(); + const off = await pollState(true); + console.log(`关机后 isOff=${off}`); + expect(off).toBe(true); + reporter.record(`${ADD_ANCHOR} 首页卡片关机`, 'PASS', Date.now() - start, '卡片浮层电源键关机成功(状态→Off)'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片关机`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片切换模式(Level/Auto/Pet/Sleep)`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + // 切模式前需先开机(关机时模式键置灰) + if (await isOff()) { await tapPower(); await pollState(false); } + const switched: string[] = []; + for (const m of MODES) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${m}")`).catch(() => null); + if (el) { await driver.tapElement(el); await sleep(2000); switched.push(m); } + else console.log(`未找到模式键: ${m}`); + } + console.log(`已切换模式: ${switched.join('/')}`); + expect(switched.length).toBe(MODES.length); + reporter.record(`${ADD_ANCHOR} 首页卡片切换模式`, 'PASS', Date.now() - start, `依次切换 ${switched.join('/')} 成功`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片切换模式`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/air_purifier/air_purifier_connect.test.ts b/tests/air_purifier/air_purifier_connect.test.ts new file mode 100644 index 0000000..38c2f99 --- /dev/null +++ b/tests/air_purifier/air_purifier_connect.test.ts @@ -0,0 +1,94 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + enterPairingMode, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Air Purifier(空气净化器):带 WiFi 模块(relay ch62)。**流程同加湿器2(Evaporative Humidifier)**: +// Connect Device 直连式(非扫描列表):长按配对键(relay ch62)→ 点 Connect Device → Discover device 直连 → WiFi 配网 → Start now。 +// 入口品类名 / 首页名 pattern 均 env 可覆盖。ONES 添加号待补,先 [P0] 占位。 +const CATEGORY = process.env.AIR_PURIFIER_CATEGORY || 'Air Purifier'; +const SCAN_KEYWORD = process.env.AIR_PURIFIER_SCAN || 'Air Purifier'; +const NAME_PATTERN = process.env.AIR_PURIFIER_PATTERN || 'Air Purifier[^"]*\\s*[0-9A-Za-z]{0,4}'; +const SKIP_SCAN = process.env.AIR_PURIFIER_SKIP_SCAN !== '0'; // 默认直连(Connect Device);=0 可回退扫描列表 +const ADD_ANCHOR = '[P0]'; // TODO: 待补 Air Purifier 添加 ONES 编号(美规 7qxA1ajE / 日规Table R5PDp8BR) + +describe('Air Purifier Connect - 添加空气净化器(WiFi模块, 长按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('AirPurifier_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加空气净化器`, { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('air_purifier', existing); + reporter.record(`${ADD_ANCHOR} 添加空气净化器`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: SKIP_SCAN, + // 选品类后:① 过 Disclaimers/Next(若有)② 长按配对键(relay ch62)进入添加模式 → 点 Connect Device/Next + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); + } + await enterPairingMode('air_purifier').catch((e) => console.log(`relay ch62 长按配对失败: ${e.message}`)); + await sleep(2000); + if (SKIP_SCAN) { + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + } + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', 'Display Type', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加空气净化器`, 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加空气净化器`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/auth/account_lifecycle.test.ts b/tests/auth/account_lifecycle.test.ts new file mode 100644 index 0000000..5c09763 --- /dev/null +++ b/tests/auth/account_lifecycle.test.ts @@ -0,0 +1,73 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { registerAccount, forgotPassword, deleteAccount, isLoggedIn, onesPlatform } from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const A_REGISTER = `① ${onesPlatform(15940)} 注册新账号`; +const A_FORGOT = `② ${onesPlatform(15941)} 忘记密码改密`; +const A_DELETE = '③ 注销账号'; // 注销暂无独立 P0 必测项 + +// 全链路:同一个临时账号 注册 → 忘记密码改密 → 注销。三步顺序共享账号。 +describe('Auth - 账号全链路(注册→忘记密码→注销)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + const account: { email: string; password: string; token: string } = { email: '', password: '', token: '' }; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Auth_FullLifecycle', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(A_REGISTER, { timeout: 240000 }, async () => { + const start = Date.now(); + try { + const acct = await registerAccount(driver); + Object.assign(account, acct); + expect(await isLoggedIn(driver)).toBe(true); + reporter.record(A_REGISTER, 'PASS', Date.now() - start, `注册并自动登录: ${acct.email}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(A_REGISTER, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(A_FORGOT, { timeout: 240000 }, async () => { + const start = Date.now(); + if (!account.email) { reporter.record(A_FORGOT, 'SKIP', 0, '注册未成功,跳过'); return; } + try { + account.password = await forgotPassword(driver, { email: account.email, token: account.token }); + expect(await isLoggedIn(driver)).toBe(true); + reporter.record(A_FORGOT, 'PASS', Date.now() - start, `${account.email} 改密并自动登录`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(A_FORGOT, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(A_DELETE, { timeout: 240000 }, async () => { + const start = Date.now(); + if (!account.email) { reporter.record(A_DELETE, 'SKIP', 0, '前置未成功,跳过'); return; } + try { + const ok = await deleteAccount(driver, { token: account.token }); + expect(ok).toBe(true); + reporter.record(A_DELETE, 'PASS', Date.now() - start, `${account.email} 注销成功`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(A_DELETE, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/auth/delete_account.test.ts b/tests/auth/delete_account.test.ts new file mode 100644 index 0000000..62a377d --- /dev/null +++ b/tests/auth/delete_account.test.ts @@ -0,0 +1,43 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { registerAccount, deleteAccount } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 注销是销毁性操作(消耗账号),故注册一个新账号再注销它,自包含可重复。 +describe('Auth - 账号注销', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + if (driver.restartApp) await driver.restartApp(); // 跨用例复位:清上个用例残留的脏态(EU区域/弹框/卡页) + reporter = new TestReporter('Auth_DeleteAccount', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it('注册账号后注销', { timeout: 300000 }, async () => { + const start = Date.now(); + try { + const acct = await registerAccount(driver); + const ok = await deleteAccount(driver, { token: acct.token }); + expect(ok).toBe(true); + reporter.record('账号注销', 'PASS', Date.now() - start, `${acct.email} 注销成功(Canceling your account)`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('账号注销', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/auth/eu_login.test.ts b/tests/auth/eu_login.test.ts new file mode 100644 index 0000000..2bb43dc --- /dev/null +++ b/tests/auth/eu_login.test.ts @@ -0,0 +1,55 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { signInWithEmail, signOut, isLoggedIn, onesPlatform } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import { getAccountCredentials, getEuAccountCredentials } from '../../config/account.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const ANCHOR = `${onesPlatform(227290)} 欧区账号登录`; // P0:欧区账号登录验证 + +describe('Auth - 欧区账号登录', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + if (driver.restartApp) await driver.restartApp(); // 跨用例复位:清上个用例残留的脏态(EU区域/弹框/卡页) + reporter = new TestReporter('Auth_EuLogin', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(ANCHOR, { timeout: 180000 }, async () => { + const start = Date.now(); + const eu = getEuAccountCredentials(); + const main = getAccountCredentials(); + try { + // 登录欧区账号 + await signInWithEmail(driver, eu); + expect(await isLoggedIn(driver)).toBe(true); + console.log(`欧区账号登录成功: ${eu.email}`); + + // 登录欧区账号后:登出,再登回常用(主)账号 + await signOut(driver); + await signInWithEmail(driver, main); + expect(await isLoggedIn(driver)).toBe(true); + console.log(`已登回常用账号: ${main.email}`); + + reporter.record(ANCHOR, 'PASS', Date.now() - start, `欧区 ${eu.email} 登录成功,并登回常用账号 ${main.email}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(ANCHOR, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/auth/forgot_password.test.ts b/tests/auth/forgot_password.test.ts new file mode 100644 index 0000000..bb969ac --- /dev/null +++ b/tests/auth/forgot_password.test.ts @@ -0,0 +1,49 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { registerAccount, forgotPassword, isLoggedIn, onesPlatform } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 自包含:先注册一个新临时账号,再对它做忘记密码改密(不依赖会被注销/过期的写死账号)。 +const ANCHOR = `${onesPlatform(15941)} 忘记密码`; // P0:忘记密码验证 + +describe('Auth - 忘记密码(自包含:注册新账号再改密)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + if (driver.restartApp) await driver.restartApp(); // 跨用例复位:清上个用例残留的脏态(EU区域/弹框/卡页) + reporter = new TestReporter('Auth_ForgotPassword', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(ANCHOR, { timeout: 300000 }, async () => { + const start = Date.now(); + try { + // 1) 注册新临时账号(返回邮箱 + mail.tm token,用于收重置码) + const acct = await registerAccount(driver); + // 2) 对该账号做忘记密码改密(forgotPassword 内部会先登出再进登录表单) + const newPwd = await forgotPassword(driver, { email: acct.email, token: acct.token }); + // 改密成功后会自动登录进账号 → isLoggedIn 即验证改密成功 + const ok = await isLoggedIn(driver); + expect(ok).toBe(true); + reporter.record(ANCHOR, 'PASS', Date.now() - start, `${acct.email} 改密为 ${newPwd} 并自动登录`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(ANCHOR, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/auth/login.test.ts b/tests/auth/login.test.ts new file mode 100644 index 0000000..c2129bd --- /dev/null +++ b/tests/auth/login.test.ts @@ -0,0 +1,61 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { signOut, signInWithEmail, isLoggedIn, onesPlatform } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import { getAccountCredentials } from '../../config/account.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const LOGOUT_ANCHOR = `${onesPlatform(15943)} 账户登出`; // P0:账户登出验证(普通邮箱登录暂无独立 P0 项) + +describe('Auth - 账号登出/登录', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + if (driver.restartApp) await driver.restartApp(); // 跨用例复位:清上个用例残留的脏态(EU区域/弹框/卡页) + reporter = new TestReporter('Auth_Login', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(LOGOUT_ANCHOR, { timeout: 120000 }, async () => { + const start = Date.now(); + try { + await signOut(driver); + // 登出后 Profile landing 出现 "Sign in" + const src = await driver.getSource(); + expect(src.includes('Sign in') || src.includes('Sign In')).toBe(true); + reporter.record(LOGOUT_ANCHOR, 'PASS', Date.now() - start, '已登出, landing 显示 Sign in'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(LOGOUT_ANCHOR, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it('账号登录', { timeout: 120000 }, async () => { + const start = Date.now(); + const cred = getAccountCredentials(); + try { + await signInWithEmail(driver, cred); + const ok = await isLoggedIn(driver); + expect(ok).toBe(true); + reporter.record('账号登录', 'PASS', Date.now() - start, `${cred.email} 登录成功`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('账号登录', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/auth/register.test.ts b/tests/auth/register.test.ts new file mode 100644 index 0000000..b59ef73 --- /dev/null +++ b/tests/auth/register.test.ts @@ -0,0 +1,44 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { registerAccount, isLoggedIn, onesPlatform } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const ANCHOR = `${onesPlatform(15940)} 账户注册`; // P0:账户注册验证 + +describe('Auth - 账号注册(临时邮箱)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + if (driver.restartApp) await driver.restartApp(); // 跨用例复位:清上个用例残留的脏态(EU区域/弹框/卡页) + reporter = new TestReporter('Auth_Register', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(ANCHOR, { timeout: 240000 }, async () => { + const start = Date.now(); + try { + const acct = await registerAccount(driver); + const ok = await isLoggedIn(driver); + expect(ok).toBe(true); + reporter.record(ANCHOR, 'PASS', Date.now() - start, `注册并登录成功: ${acct.email}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(ANCHOR, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/auth/third_party_login.test.ts b/tests/auth/third_party_login.test.ts new file mode 100644 index 0000000..1b4ae4b --- /dev/null +++ b/tests/auth/third_party_login.test.ts @@ -0,0 +1,56 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { signInWithGoogle, signOut, signInWithEmail, isLoggedIn, onesPlatform } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const ANCHOR = `${onesPlatform(15942)} 第三方登录`; // P0:第三方(Google)登录验证 + +describe('Auth - 第三方登录', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + if (driver.restartApp) await driver.restartApp(); // 跨用例复位:清上个用例残留的脏态(EU区域/弹框/卡页) + reporter = new TestReporter('Auth_ThirdPartyLogin', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(ANCHOR, { timeout: 150000 }, async () => { + const start = Date.now(); + try { + // 第三方(Google)登录:登录页 → Google → 系统账号浮层继续 → 回 App 登录成功 + const ok = await signInWithGoogle(driver); + expect(ok).toBe(true); + reporter.record(ANCHOR, 'PASS', Date.now() - start, 'Google 第三方登录成功'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(ANCHOR, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } finally { + // 还原:登出 Google 账号,登回主账号,避免污染其它用例 + try { + await signOut(driver); + await signInWithEmail(driver); + await isLoggedIn(driver); + } catch (e: any) { console.log(`还原主账号失败(不影响本用例结果): ${e.message}`); } + } + }); + + // Apple 第三方登录:UI 路径已摸通(登录页 → More → Apple → webview 填 Apple ID → Continue + // → 填密码 → Sign In),但 Apple 在账号密码后强制要求**验证码(2FA)**,无可靠自动化来源 → 跳过。 + // 第三方登录的 P0 覆盖由上面的 Google 路径完成。 + it.skip('第三方登录-Apple(需 2FA 验证码,无法自动化)', async () => {}); +}); diff --git a/tests/bot/bot_card.test.ts b/tests/bot/bot_card.test.ts index f5edc3b..ff5af2e 100644 --- a/tests/bot/bot_card.test.ts +++ b/tests/bot/bot_card.test.ts @@ -6,6 +6,7 @@ import { BOT_LOCATORS } from '../../locators/bot-locators'; import { TestReporter } from '../../utils/test-reporter'; import { getDeviceName } from '../../config/device.config'; import { sleep } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; import { applyProtoNetwork } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; @@ -23,14 +24,19 @@ describe('Bot Card - 首页卡片操作', () => { let driver: DeviceDriver; let bot: BotHelper; let reporter: TestReporter; + setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); await driver.createSession(); bot = new BotHelper(driver); reporter = new TestReporter('Bot_Card', driver.platform.toUpperCase()); - // 双协议前置:按 PROTO 切手机蓝牙/WiFi(ble→开蓝牙关WiFi / wifi→关蓝牙开WiFi),无人值守自动切 - await applyProtoNetwork(driver, PROTO); + // 双协议前置:按 PROTO 切手机蓝牙/WiFi。切换本身偶发失败(adb dump 被杀), + // 不应拖垮整个控制 suite —— 包成非致命(手机蓝牙通常已开,BLE 控制照常)。 + if (process.env.SKIP_PROTO_NET !== '1') { + try { await applyProtoNetwork(driver, PROTO); } + catch (e: any) { console.log(`applyProtoNetwork 跳过(非致命): ${e.message}`); } + } }); beforeEach(async () => { @@ -120,8 +126,9 @@ describe('Bot Card - 首页卡片操作', () => { 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/sivMode")'); if (!sivMode) return false; await driver.tapElement(sivMode); - await sleep(1500); - const targetId = await driver.findElementRaw('name', targetMode); + await sleep(2000); + let targetId = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${targetMode}")`); + if (!targetId) { await sleep(1500); targetId = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${targetMode}")`); } if (!targetId) { const cancelId = await driver.findElementRaw('name', 'Cancel'); if (cancelId) await driver.tapElement(cancelId); @@ -238,72 +245,54 @@ describe('Bot Card - 首页卡片操作', () => { } }); - it('Switch模式 - ON/OFF切换', async () => { + it('Switch模式 - ON/OFF切换', { timeout: 150000 }, async () => { const start = Date.now(); + // 从控制弹窗读状态:弹窗显示状态文本 "On"/"Off"(与按钮 "ON"/"OFF" 大小写不同) + const readStatusFromPopup = async (): Promise<'on' | 'off' | 'unknown'> => { + const s = await driver.getSource(); + if (/text="Off"/.test(s)) return 'off'; + if (/text="On"/.test(s)) return 'on'; + return 'unknown'; + }; try { - // Ensure device is in Switch Mode before attempting ON/OFF - let statusBefore = await bot.getBotStatus(); - console.log('初始状态:', statusBefore); - - if (statusBefore === 'unknown') { - console.log('设备可能非Switch Mode,先切换到Switch Mode...'); - await navigateToModeSettings(); - const currentMode = await getCurrentMode(); - console.log('当前模式:', currentMode); - - if (currentMode !== 'Switch') { - const switched = await switchToMode('Switch Mode'); - console.log('切换到Switch Mode:', switched); - await sleep(2000); - } - - // Go back to homepage and re-check status - await driver.goBackToHomepage(); - await sleep(2000); - statusBefore = await bot.getBotStatus(); - console.log('切换后状态:', statusBefore); - - if (statusBefore === 'unknown') { - reporter.record(`${CTRL} 不加密开/关`, 'SKIP', Date.now() - start, '切换Switch Mode后仍无法识别状态'); - return; - } + // 前置:用模式切换脚本确保 Switch 模式(Press 模式无 ON/OFF) + await navigateToModeSettings(); + if ((await getCurrentMode()) !== 'Switch') { + const ok = await switchToMode('Switch Mode'); + console.log('切换到 Switch Mode:', ok); + await sleep(2500); } - - await tapBotAndWaitPopup(); - - if (statusBefore === 'off') { - const onId = await driver.findElement(BOT_LOCATORS.onButton); - expect(onId).not.toBeNull(); - await driver.tapElement(onId!); - } else { - const offId = await driver.findElement(BOT_LOCATORS.offButton); - expect(offId).not.toBeNull(); - await driver.tapElement(offId!); - } - - // Wait for BLE command to complete, then go back to homepage to read updated card status - await sleep(5000); await driver.goBackToHomepage(); await sleep(2000); - const statusAfter = await bot.getBotStatus(); - console.log('操作后:', statusAfter); - expect(statusAfter).not.toBe('unknown'); - expect(statusAfter).not.toBe(statusBefore); - - const detail = `${statusBefore} → ${statusAfter}`; - reporter.record(`${CTRL} 不加密开/关`, 'PASS', Date.now() - start, detail); - - // Restore state + // 打开控制弹窗读初始状态 await tapBotAndWaitPopup(); - if (statusBefore === 'off') { - const offId = await driver.findElement(BOT_LOCATORS.offButton); - if (offId) await driver.tapElement(offId); - } else { - const onId = await driver.findElement(BOT_LOCATORS.onButton); - if (onId) await driver.tapElement(onId); - } - await sleep(5000); + const before = await readStatusFromPopup(); + console.log('弹窗初始状态:', before); + expect(before).not.toBe('unknown'); // Switch 模式弹窗应能读到 On/Off + + // 3. 点相反按钮控制 + const targetBtn = before === 'off' ? 'ON' : 'OFF'; + const btn = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${targetBtn}")`); + expect(btn).not.toBeNull(); + await driver.tapElement(btn!); + await sleep(6000); // 等 BLE 命令 + 状态回报 + + // 4. 重新开弹窗读状态,校验翻转(状态没更新=设备连接问题) + await driver.goBackToHomepage(); + await sleep(2000); + await tapBotAndWaitPopup(); + const after = await readStatusFromPopup(); + console.log('操作后状态:', after); + expect(after).not.toBe('unknown'); + expect(after).not.toBe(before); + + reporter.record(`${CTRL} 不加密开/关`, 'PASS', Date.now() - start, `${before} → ${after}(弹窗校验)`); + + // 还原 + const restoreBtn = before === 'off' ? 'OFF' : 'ON'; + const rb = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${restoreBtn}")`); + if (rb) { await driver.tapElement(rb); await sleep(5000); } } catch (e: any) { const ss = await captureScreenshot(); reporter.record(`${CTRL} 不加密开/关`, 'FAIL', Date.now() - start, e.message, ss); @@ -315,10 +304,19 @@ describe('Bot Card - 首页卡片操作', () => { const start = Date.now(); try { await tapBotAndWaitPopup(); - const settingsId = await driver.findElement(BOT_LOCATORS.settingsButton); - expect(settingsId).not.toBeNull(); - await driver.tapElement(settingsId!); - await sleep(2000); + // Android:点卡片通常直接进设置页;若是小弹窗(没出现 Mode 菜单),再点 "Settings" 进入 + if (driver.platform === 'ios') { + const settingsId = await driver.findElement(BOT_LOCATORS.settingsButton); + expect(settingsId).not.toBeNull(); + await driver.tapElement(settingsId!); + await sleep(2000); + } else { + const hasMenu = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Mode")'); + if (!hasMenu) { + const s = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Settings")'); + if (s) { await driver.tapElement(s); await sleep(2000); } + } + } const items = ['modeItem', 'passcodeItem', 'schedulesItem', 'logsItem', 'nfcItem'] as const; const found: string[] = []; @@ -355,11 +353,12 @@ describe('Bot Card - 首页卡片操作', () => { async function enterSettings(): Promise { await tapBotAndWaitPopup(); - if (driver.platform === 'ios') { - const settingsId = await driver.findElement(BOT_LOCATORS.settingsButton); - if (!settingsId) throw new Error('Settings按钮未找到'); - await driver.tapElement(settingsId); - await sleep(1500); + // 在线时点卡片是控制弹窗(有 Settings 入口,无 Passcode)→ 点 Settings 进设置页;离线时已是设置页 + const onSettings = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Passcode")'); + if (!onSettings) { + const settings = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Settings")') + || await driver.findElementRaw('name', 'Settings'); + if (settings) { await driver.tapElement(settings); await sleep(2000); } } } @@ -390,74 +389,49 @@ describe('Bot Card - 首页卡片操作', () => { return true; } - it('设置加密密码', async () => { + it('设置加密密码', { timeout: 90000 }, async () => { const start = Date.now(); try { await enterSettings(); - + // 进 Passcode 页 const tapped = await scrollToAndTap('Passcode'); expect(tapped).toBe(true); await sleep(2000); - - // On Android, may need to dismiss existing password dialog first await dismissPasswordDialogIfPresent(); - // Check if password already set (Delete visible) — if so, delete first - let deleteEl = await driver.findElementRaw('name', 'Delete'); - if (deleteEl) { - await driver.tapElement(deleteEl); - await sleep(1500); - const okEl = await driver.findElementRaw('name', 'OK') || await driver.findElementRaw('name', 'Confirm') || await driver.findElementRaw('name', 'Done'); - if (okEl) await driver.tapElement(okEl); + // 若已是 "Not set" 才走设密码流程(已设则跳过设置,直接按"无 Not set"判通过) + let pcSrc = await driver.getSource(); + if (pcSrc.includes('Not set')) { + const pwItem = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/passwordItemView")'); + expect(pwItem).not.toBeNull(); + await driver.tapElement(pwItem!); await sleep(2000); - } - if (driver.platform === 'android') { - // Android: tap passwordItemView to open set password dialog - const pwdItem = await driver.findElementRaw('-android uiautomator', - 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/passwordItemView")'); - if (pwdItem) { - await driver.tapElement(pwdItem); - await sleep(2000); - } + const e1 = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editPwd")'); + const e2 = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editPwdRepeat")'); + expect(e1).not.toBeNull(); + expect(e2).not.toBeNull(); + await driver.typeText(e1!, '1234'); + await sleep(400); + await driver.typeText(e2!, '1234'); + await sleep(600); + // 不要 goBack(Android BACK 会关对话框)。直接点 btnConfirm(在键盘上方) + const confirm = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/btnConfirm")'); + expect(confirm).not.toBeNull(); + await driver.tapElement(confirm!); + await sleep(4000); + await driver.dismissPopupIfPresent(); } else { - // iOS: tap "Set Password" or "Set Passcode" - const setEl = await driver.findElementRaw('name', 'Set Password') || await driver.findElementRaw('name', 'Set Passcode'); - if (setEl) { - await driver.tapElement(setEl); - await sleep(2000); - } + console.log('密码已设置(非 Not set),按"无 Not set"判通过'); } - // Input password via text fields - const editFields = await driver.findElementsRaw('class name', driver.platform === 'android' ? 'android.widget.EditText' : 'XCUIElementTypeTextField'); - const secureFields = driver.platform === 'ios' ? await driver.findElementsRaw('class name', 'XCUIElementTypeSecureTextField') : []; - const fields = editFields.length > 0 ? editFields : secureFields; + // 通过判据:密码页不再显示 "Not set" + const after = await driver.getSource(); + const stillNotSet = after.includes('Not set'); + console.log('设密码后仍 Not set:', stillNotSet); + expect(stillNotSet).toBe(false); - if (fields.length >= 2) { - await driver.typeText(fields[0], '1234'); - await sleep(500); - await driver.typeText(fields[1], '1234'); - await sleep(500); - } else if (fields.length === 1) { - await driver.typeText(fields[0], '1234'); - await sleep(500); - } - - // Tap OK/Confirm - const confirmEl = await driver.findElementRaw('name', 'OK') || await driver.findElementRaw('name', 'Confirm'); - if (confirmEl) { - await driver.tapElement(confirmEl); - await sleep(3000); - } - - // Verify password is set - let source = await driver.getSource(); - const isSet = source.includes('Delete') || source.includes('Change Password') || source.includes('****') || !source.includes('Not set'); - console.log('密码设置结果:', isSet ? '已设置' : '未设置'); - expect(isSet).toBe(true); - - reporter.record('设置加密密码', 'PASS', Date.now() - start, `密码1234设置=${isSet}`); + reporter.record('设置加密密码', 'PASS', Date.now() - start, '密码已设置(密码页无 Not set)'); } catch (e: any) { const ss = await captureScreenshot(); reporter.record('设置加密密码', 'FAIL', Date.now() - start, e.message, ss); @@ -548,7 +522,7 @@ describe('Bot Card - 首页卡片操作', () => { } }); - it('删除加密密码', async () => { + it('删除加密密码', { timeout: 90000 }, async () => { const start = Date.now(); try { await enterSettings(); @@ -575,26 +549,28 @@ describe('Bot Card - 首页卡片操作', () => { if (deleteEl) { await driver.tapElement(deleteEl); await sleep(2000); - - // Confirmation dialog - const confirmButtons = ['OK', 'Confirm', 'Delete', 'Done']; - for (const btn of confirmButtons) { - const el = await driver.findElementRaw('name', btn); - if (el) { await driver.tapElement(el); await sleep(3000); break; } + // 确认框:btnConfirm / OK / Delete + const confirm = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/btnConfirm")'); + if (confirm) { await driver.tapElement(confirm); await sleep(3000); } + else { + for (const btn of ['OK', 'Confirm', 'Delete', 'Done']) { + const el = await driver.findElementRaw('name', btn); + if (el) { await driver.tapElement(el); await sleep(3000); break; } + } } } else { console.log('Delete按钮未找到,密码可能未设置'); } - await sleep(1000); + await sleep(1500); + await driver.dismissPopupIfPresent(); + // 严格校验:删除后密码页应重新显示 "Not set"(与设置对称) const source = await driver.getSource(); - const isDeleted = source.includes('Not set') || source.includes('Set Password') || - source.includes('Set Passcode') || source.includes('Passcode') || - !source.includes('Delete'); - console.log('密码删除:', isDeleted ? '成功' : '未确认'); + const isDeleted = source.includes('Not set'); + console.log('删除后是否 Not set:', isDeleted); expect(isDeleted).toBe(true); - reporter.record('删除加密密码', 'PASS', Date.now() - start, `密码删除, Not set=${isDeleted}`); + reporter.record('删除加密密码', 'PASS', Date.now() - start, '密码已删除(行值回到 Not set)'); } catch (e: any) { const ss = await captureScreenshot(); reporter.record('删除加密密码', 'FAIL', Date.now() - start, e.message, ss); diff --git a/tests/camera/camera_stream.test.ts b/tests/camera/camera_stream.test.ts new file mode 100644 index 0000000..7cf51dc --- /dev/null +++ b/tests/camera/camera_stream.test.ts @@ -0,0 +1,147 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { sleep } from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 摄像头拉流(P0 15974 出流类):点击摄像头 → 点画面唤出 overlay → 出流成功(KB/S) → 停留 N ms 复检流未断。 +// 配置 = 当前账号已添加的摄像头(首页实际卡片名,2026-06 dump)。不做添加,直接用。 +// ones 为对应 15974 step;Plus 2K/3K 与 ONES 的 Plus 3MP/5MP 为近似映射(实际型号以首页名为准)。 +interface CamCfg { name: string; ones: string; } +// Android 账号摄像头(首页实际卡名) +const CAMERAS_ANDROID: CamCfg[] = [ + { name: 'Indoor Cam fn', ones: '15974#4oeeJEV8' }, // Indoor Cam + { name: 'Pan/Tilt Cam vo', ones: '15974#16szaUdD' }, // Pan/Tilt Cam + { name: 'Pan/Tilt Cam 2K oy', ones: '15974#Hj5Z9jSf' }, // Pan/Tilt Cam 2K + { name: 'Pan/Tilt Cam Plus 2K 06', ones: '15974#SaKmFWLw' }, // PTC Plus 3MP(近似) + { name: 'Pan/Tilt Cam Plus 3K 09', ones: '15974#LDqSDPPT' }, // PTC Plus 5MP(近似) + { name: 'Outdoor Spotlight Cam 2K jx', ones: '15974#WH7kyypF' }, // OSC 2K +]; +// iOS 账号摄像头(首页实际卡名,2026-06 dump):Pan/Tilt Cam 2K 6O / Pan/Tilt Cam Plus 3K B3 / Outdoor Spotlight Cam 2K FV。 +// 用型号子串(CONTAINS 匹配,忽略设备后缀)。 +const CAMERAS_IOS: CamCfg[] = [ + { name: 'Pan/Tilt Cam 2K', ones: '15974#Hj5Z9jSf' }, + { name: 'Pan/Tilt Cam Plus 3K', ones: '15974#LDqSDPPT' }, + { name: 'Outdoor Spotlight Cam 2K', ones: '15974#WH7kyypF' }, +]; +const CAMERAS: CamCfg[] = (process.env.PLATFORM || 'android').toLowerCase() === 'ios' ? CAMERAS_IOS : CAMERAS_ANDROID; + +// 调试默认 15s;P0 正式跑设 STREAM_HOLD_MS=180000(3min)。CAMERA_NAME 设了则只跑该摄像头。 +const HOLD_MS = parseInt(process.env.STREAM_HOLD_MS || '15000', 10); +const STREAM_TIMEOUT = parseInt(process.env.STREAM_TIMEOUT || '30000', 10); +const ONLY = process.env.CAMERA_NAME || ''; +const TARGETS = ONLY ? CAMERAS.filter((c) => c.name.includes(ONLY)) : CAMERAS; + +describe('Camera Stream - 摄像头拉流(出流+停留)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + let screenWidth = 1080; + let screenHeight = 2280; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Camera_Stream', driver.platform.toUpperCase()); + const size = await driver.getWindowSize(); + screenWidth = size.width; + screenHeight = size.height; + }); + + beforeEach(async () => { + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); + await driver.dismissPopupIfPresent(); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + // 在 RN 首页找摄像头卡片并点击进入功能页(scrollIntoView 对 RN 首页失效 → 手动滚动找) + async function tapCameraCard(name: string): Promise { + for (let i = 0; i < 8; i++) { + let el: string | null = null; + if (driver.platform === 'ios') { + el = await driver.findElementRaw('predicate string', `name CONTAINS "${name}" AND type == "XCUIElementTypeCell"`).catch(() => null); + } else { + el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${name}")`).catch(() => null); + } + if (el) { + const rect = await driver.getElementRect(el); + // 点卡片缩略图区域(名字在缩略图下方)进功能页 + await driver.tap(rect.x + Math.min(rect.width / 2, 120), Math.max(rect.y - 40, rect.y + 20)); + await sleep(6000); + return true; + } + await driver.scrollDown(500); + await sleep(800); + } + return false; + } + + // 点画面唤出 overlay(KB/S 指标点一下才显示,且会自动隐藏) + async function tapVideoArea(): Promise { + await driver.tap(Math.round(screenWidth / 2), Math.round(screenHeight * 0.25)); + await sleep(800); + } + + // 等待出流:点画面唤出 overlay,KB/S 指标出现 = 拉流成功 + async function waitStreamLive(timeout: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + await tapVideoArea(); + const s = await driver.getSource(); + if (/Disconnected|Connection failed|连接失败|设备离线|Device offline/i.test(s)) { + throw new Error('摄像头未连接/离线'); + } + if (/KB\/S|MB\/S|KB\/s|kb\/s/i.test(s)) return true; + await sleep(1500); + } + return false; + } + + for (const cam of TARGETS) { + it(`[P0][ONES:${cam.ones}][wifi] 拉流 ${cam.name} 出流成功并停留${Math.round(HOLD_MS / 1000)}s`, { timeout: HOLD_MS + 120000 }, async () => { + const start = Date.now(); + const label = `拉流${cam.name}`; + try { + const entered = await tapCameraCard(cam.name); + if (!entered) throw new Error(`首页找不到摄像头卡片 "${cam.name}"`); + + const live = await waitStreamLive(STREAM_TIMEOUT); + if (!live) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(label, 'FAIL', Date.now() - start, '进入功能页但未检测到出流(KB/S)', ss); + throw new Error('出流超时(未出现 KB/S)'); + } + console.log(`${cam.name} 出流成功,停留 ${HOLD_MS}ms 复检...`); + + const checkpoints = Math.max(1, Math.round(HOLD_MS / 30000)); + const interval = Math.round(HOLD_MS / checkpoints); + for (let i = 0; i < checkpoints; i++) { + await sleep(interval); + const s = await driver.getSource(); + if (/Disconnected|Connection failed|连接失败/i.test(s)) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(label, 'FAIL', Date.now() - start, `停留中流断开(第${i + 1}/${checkpoints}次复检)`, ss); + throw new Error('停留期间流断开'); + } + console.log(` ${cam.name} 停留复检 ${i + 1}/${checkpoints} OK (+${Math.round((Date.now() - start) / 1000)}s)`); + } + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(label, 'PASS', Date.now() - start, `出流成功并稳定停留${Math.round(HOLD_MS / 1000)}s, 耗时${elapsed}s`); + expect(live).toBe(true); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(label, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + } +}); diff --git a/tests/camera/doorbell_connect.test.ts b/tests/camera/doorbell_connect.test.ts new file mode 100644 index 0000000..8114ee3 --- /dev/null +++ b/tests/camera/doorbell_connect.test.ts @@ -0,0 +1,88 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + enterPairingMode, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 门铃(Video Doorbell):WiFi 产品。配对 = 同时长按 通话(ch36)+挂断(ch37)键 5s。流程含 WiFi 配网;实测简单流程(Next→配对→Connect Device)即可通。 +const CATEGORY = process.env.DOORBELL_CATEGORY || 'Video Doorbell'; +const SCAN_KEYWORD = process.env.DOORBELL_SCAN || 'Doorbell'; +const NAME_PATTERN = process.env.DOORBELL_PATTERN || '(Doorbell|Video Doorbell)\\s*[0-9A-Za-z]{0,4}'; + +describe('Doorbell Connect - 添加门铃(同时按通话+挂断进配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Doorbell_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it('[P0][ONES:197389] 通过BLE+WiFi添加门铃', { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('doorbell', existing); + reporter.record('[P0][ONES:197389] 添加门铃', 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: true, + // 配对:同时按 通话(ch34)+挂断(ch35)2s(enterPairingMode 读 doorbell.pairing 动作)→ 点 Next/Connect Device + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); // 过 Disclaimers(若有) + } + await enterPairingMode('doorbell').catch((e) => console.log(`relay 同时按34+35失败: ${e.message}`)); + await sleep(2000); + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record('[P0][ONES:197389] 添加门铃', 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('[P0][ONES:197389] 添加门铃', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/ceiling_light/ceiling_light_pro_connect.test.ts b/tests/ceiling_light/ceiling_light_pro_connect.test.ts new file mode 100644 index 0000000..dbf556d --- /dev/null +++ b/tests/ceiling_light/ceiling_light_pro_connect.test.ts @@ -0,0 +1,91 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + pressButton, + powerCycle, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 吸顶灯 Pro:WiFi 产品(relay ch1)。添加流程同 Color Bulb:**点一次→等2s→再点一次** 进配对 + WiFi 配网;仅品类入口不同。 +const CATEGORY = process.env.CEILING_PRO_CATEGORY || 'Ceiling Light Pro'; +const SCAN_KEYWORD = process.env.CEILING_PRO_SCAN || 'Ceiling Light'; // 扫描列表里的名字 +const NAME_PATTERN = process.env.CEILING_PRO_PATTERN || 'Ceiling Light( Pro)?\\s*[0-9A-Za-z]{0,4}'; + +describe('CeilingLight Pro Connect - 添加吸顶灯Pro(两次点按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('CeilingLightPro_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it('[P0][ONES:15950] 通过BLE+WiFi添加吸顶灯Pro', { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('ceilingLight', existing); + reporter.record('[P0][ONES:15950] 添加吸顶灯Pro', 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: true, // 配对后点 Next 直接连接(无扫描列表) + // 配对:点一次→等2s→再点一次(ch1 按钮,实测能进配对;App 指引的"电源循环"文案对此 relay 接法无效)→ 点 Next(引导页完成按钮) + preSelectSteps: async (d) => { + await pressButton('ceiling_light_pro', 'main', 400).catch((e) => console.log(`relay ch1 按键失败: ${e.message}`)); + await sleep(2000); + await pressButton('ceiling_light_pro', 'main', 400).catch((e) => console.log(`relay ch1 第二次按键失败: ${e.message}`)); + await sleep(2000); + const next = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (next) { await d.tapElement(next); await sleep(3000); } + else console.log('引导页未找到 Next'); + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + // 命名页完成按钮是 "Start now"(默认向导没有)→ 必须加,否则设备不落账号。Return Home 兜底。 + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + ], + }); + expect(result).toBe(true); + + // 添加完成后吸顶灯通常为打开状态 → 按一次按钮关灯(避免长亮) + await pressButton('ceiling_light_pro', 'main', 400).catch((e) => console.log(`关灯按键失败: ${e.message}`)); + await sleep(1000); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record('[P0][ONES:15950] 添加吸顶灯Pro', 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('[P0][ONES:15950] 添加吸顶灯Pro', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/ceiling_light/color_floor_lamp_connect.test.ts b/tests/ceiling_light/color_floor_lamp_connect.test.ts new file mode 100644 index 0000000..44fb7f5 --- /dev/null +++ b/tests/ceiling_light/color_floor_lamp_connect.test.ts @@ -0,0 +1,92 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + pressButton, + enterPairingMode, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 炫彩落地灯(RGBIC Floor Lamp):灯类 WiFi 产品(relay ch44)。流程同落地灯/灯带/霓虹灯:长按配对键 2s + Connect Device 直连 + WiFi 配网 + Start now。入口品类不同。 +const CATEGORY = process.env.COLOR_FLOOR_LAMP_CATEGORY || 'RGBICWW Floor Lamp'; +const SCAN_KEYWORD = process.env.COLOR_FLOOR_LAMP_SCAN || 'Floor Lamp'; +const NAME_PATTERN = process.env.COLOR_FLOOR_LAMP_PATTERN || 'Floor Lamp[^"]*\\s*[0-9A-Za-z]{0,4}'; + +describe('Color Floor Lamp Connect - 添加炫彩落地灯(长按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('ColorFloorLamp_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it('[P0] 通过BLE+WiFi添加炫彩落地灯', { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('ceilingLight', existing); + reporter.record('[P0] 添加炫彩落地灯', 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: true, // Connect Device 直连进配网页,无扫描列表 + // 选品类后:① 过 Disclaimers(若有)② 长按配对键 2s(relay ch44)→ 点 Next/Connect Device + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); + } + await enterPairingMode('color_floor_lamp').catch((e) => console.log(`relay ch44 长按配对失败: ${e.message}`)); + await sleep(2000); + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + ], + }); + expect(result).toBe(true); + + await pressButton('color_floor_lamp', 'main', 400).catch((e) => console.log(`关灯按键失败: ${e.message}`)); // 添加后关灯 + await sleep(1000); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record('[P0] 添加炫彩落地灯', 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('[P0] 添加炫彩落地灯', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/ceiling_light/eave_light_connect.test.ts b/tests/ceiling_light/eave_light_connect.test.ts new file mode 100644 index 0000000..c3efe48 --- /dev/null +++ b/tests/ceiling_light/eave_light_connect.test.ts @@ -0,0 +1,112 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + pressButton, + enterPairingMode, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 屋檐灯(Permanent Outdoor Lights):灯类 WiFi 产品(relay ch38)。**流程同落地灯(Floor Lamp)**: +// 长按配对键 2s + Connect Device 直连 + WiFi 配网 + Start now。入口品类名「Permanent Outdoor Lights」。 +// ONES 添加号待补,先 [P0] 占位。 +const CATEGORY = process.env.EAVE_LIGHT_CATEGORY || 'Permanent Outdoor Lights'; +const SCAN_KEYWORD = process.env.EAVE_LIGHT_SCAN || 'Permanent Outdoor Lights'; +const NAME_PATTERN = process.env.EAVE_LIGHT_PATTERN || 'Permanent Outdoor Lights[^"]*\\s*[0-9A-Za-z]{0,4}'; +const ADD_ANCHOR = '[P0]'; // TODO: 待补 屋檐灯 添加 ONES 编号 + +describe('Eave Light Connect - 添加屋檐灯(长按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('EaveLight_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加屋檐灯`, { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('ceilingLight', existing); + reporter.record(`${ADD_ANCHOR} 添加屋檐灯`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: true, // Connect Device 直连进配网页,无扫描列表 + // 屋檐灯流程:推进到「发现页」(Discover device/Connect device)→ 长按配对键 2s(relay ch38)→ 点 Connect device → title 含 add + preSelectSteps: async (d) => { + // 1. 推进到发现页 + for (let i = 0; i < 4; i++) { + const src = await d.getSource(); + if (/Discover device|Connect device/i.test(src)) break; + let moved = false; + for (const t of ['Next', 'Connect']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { await d.tapElement(b); moved = true; break; } + } + if (!moved) break; + await sleep(2000); + } + // 2. 长按配对键(relay ch38)进配对 + await enterPairingMode('eave_light').catch((e) => console.log(`relay ch38 长按配对失败: ${e.message}`)); + await sleep(2500); + // 3. 点发现页的 Connect device 触发发现 + let clicked = ''; + for (const t of ['Connect device', 'Connect Device', 'Connect Devices', 'Connect', 'Next']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { await d.tapElement(b); clicked = t; break; } + } + await sleep(3000); + const src = await d.getSource(); + const inAdd = /add/i.test(src); + const btns = [...new Set(Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t && t.length < 24))].slice(0, 25); + console.log(`[屋檐灯诊断] 发现页点击="${clicked}" 已进添加页(含add)=${inAdd}`); + console.log(`[屋檐灯诊断] 页面文案: ${JSON.stringify(btns)}`); + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + ], + }); + expect(result).toBe(true); + + await pressButton('eave_light', 'main', 400).catch((e) => console.log(`关灯按键失败: ${e.message}`)); // 添加后关灯 + await sleep(1000); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加屋檐灯`, 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加屋檐灯`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/ceiling_light/eave_light_control.test.ts b/tests/ceiling_light/eave_light_control.test.ts new file mode 100644 index 0000000..ae28b65 --- /dev/null +++ b/tests/ceiling_light/eave_light_control.test.ts @@ -0,0 +1,251 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { sleep, waitForSource } from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 灯类首页卡片控制(以屋檐灯 Permanent Outdoor Lights 为样板,卡片关键词可配,可复用到同款浮层滑条灯)。 +// 浮层:状态字(Off / "On | NN% | Color") + **无文案电源圆键** + **亮度滑条** + More。流程同加湿器2/空净。 +// 实测 1080x2400:电源键中心 (540,1455)=(w/2,h*0.606);滑条 track x[48..1032] y1737(h*0.724)。 +// 亮度可由副标题 "On | NN%" 直接读出,拖滑条后校验 % 变化。ONES 控制号待补,先 [P0] 占位。 +const CARD_KEYWORD = process.env.LIGHT_CARD || 'Permanent Outdoor Lights'; +const ADD_ANCHOR = '[P0]'; // TODO: 待补 屋檐灯/灯类 控制 ONES 编号 + +describe('Light Card - 首页卡片控制(电源+亮度滑条)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + let W = 1080, H = 2400; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Light_Card', driver.platform.toUpperCase()); + try { const s = await driver.getWindowSize(); if (s?.width) { W = s.width; H = s.height; } } catch { /* 默认 1080x2400 */ } + }); + + beforeEach(async () => { + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); + await sleep(500); + await driver.dismissPopupIfPresent(); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + async function findCard(): Promise { + // 先滚到顶复位(避免上条用例滚动后卡片落在视窗上方找不到) + for (let i = 0; i < 4; i++) { await driver.swipe(540, 700, 540, 1600, 0.3).catch(() => {}); await sleep(300); } + for (let r = 0; r < 10; r++) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null); + if (el) return el; + await driver.swipe(540, 1500, 540, 700, 0.4).catch(() => {}); + await sleep(900); + } + throw new Error(`找不到${CARD_KEYWORD}卡片`); + } + + async function openCardPopup(): Promise { + const cardId = await findCard(); + const rect = await driver.getElementRect(cardId); + await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); + await sleep(2000); + if (!(await waitForSource(driver, 'More', 6000))) throw new Error('卡片快捷控制浮层未弹出'); + } + + // 动态找电源键:浮层里近正方形(150-300px)、水平居中的可点节点。找不到回退比例点。 + async function tapPower(): Promise { + const src = await driver.getSource(); + const cands = Array.from(src.matchAll(/clickable="true"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g)) + .map((m) => { const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4]; return { w: x2 - x1, h: y2 - y1, cx: (x1 + x2) / 2, cy: (y1 + y2) / 2 }; }) + .filter((b) => b.w >= 150 && b.w <= 320 && b.h >= 150 && b.h <= 320 && Math.abs(b.cx - W / 2) < W * 0.15 && b.cy > H * 0.4); + const px = cands.length ? Math.round(cands[0].cx) : Math.round(W * 0.5); + const py = cands.length ? Math.round(cands[0].cy) : Math.round(H * 0.606); + console.log(`点电源键 (${px}, ${py})${cands.length ? '' : ' [回退默认]'}`); + return driver.tap(px, py); + } + + // 读副标题:首个含设备名后的状态字。关="Off";开="On | NN% | Color"。 + async function readStatus(): Promise { + const src = await driver.getSource(); + const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); + const i = texts.findIndex((t) => t.includes(CARD_KEYWORD)); + return i >= 0 ? (texts[i + 1] || '') : ''; + } + async function isOff(): Promise { + const s = await readStatus(); + console.log(`状态字="${s}"`); + return /^Off$|Offline/.test(s); + } + // 当前亮度%:扫全部文本节点找含 NN% 的那条(色温/模式无%,唯一的%即亮度;比 texts[i+1] 位置法稳)。 + // 偶发瞬时读不到 → 重试;仍无返回 -1。 + async function readBrightness(retries = 4): Promise { + for (let i = 0; i < retries; i++) { + const src = await driver.getSource(); + for (const m of src.matchAll(/text="([^"]+)"/g)) { + const mm = m[1].match(/(\d+)\s*%/); + if (mm) return Number(mm[1]); + } + await sleep(600); + } + return -1; + } + + async function pollState(wantOff: boolean, ms = 15000): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if ((await isOff()) === wantOff) return true; + await sleep(1500); + } + return (await isOff()) === wantOff; + } + + // 动态找滑条 track:浮层里宽 View(w>屏宽80%、高<280)。排除屏幕底部(y1> { + const src = await driver.getSource(); + const tracks = Array.from(src.matchAll(/class="android\.view\.View"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g)) + .map((m) => { const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4]; return { x1, y1, w: x2 - x1, h: y2 - y1, left: x1 + 10, right: x2 - 10, y: Math.round((y1 + y2) / 2) }; }) + .filter((b) => b.w > W * 0.8 && b.h > 40 && b.h < 280 && b.y1 > H * 0.4 && b.y1 < H * 0.88); + return tracks.sort((a, b) => a.y - b.y); + } + + // 缓存亮度滑条。多滑条时用「幅度+方向」判定:真亮度条=拖低读低%、拖高读高%、且高-低≥25(色温条拖动时亮度%基本不变,被排除;反向条 高<低 也排除)。 + let brightnessTrack: { left: number; right: number; y: number } | null = null; + // 触点 x 夹紧到远离屏幕左右边缘(避免 Android 边缘返回手势把浮层划掉 —— 亮度100%时滑块在最右端尤甚) + const clampX = (x: number) => Math.max(Math.round(W * 0.085), Math.min(Math.round(W * 0.915), x)); + async function dragOnTrack(tr: { left: number; right: number; y: number }, toF: number): Promise { + const cur = await readBrightness(); + const curF = cur >= 0 ? cur / 100 : 0.5; + const sx = clampX(Math.round(tr.left + curF * (tr.right - tr.left))); // 从当前滑块(按亮度%反算)起拖 + const tx = clampX(Math.round(tr.left + toF * (tr.right - tr.left))); + await driver.swipe(sx, tr.y, tx, tr.y, 0.8); + await sleep(2000); + } + async function ensureBrightnessTrack(): Promise { + if (brightnessTrack) return; + const tracks = await findSliderTracks(); + for (const tr of tracks) { + await dragOnTrack(tr, 0.25); const lo = await readBrightness(); + await dragOnTrack(tr, 0.8); const hi = await readBrightness(); + console.log(`试探滑条 y=${tr.y}: 拖低→${lo}% 拖高→${hi}%`); + if (hi > 0 && lo > 0 && hi > lo && hi - lo >= 25) { brightnessTrack = tr; console.log(`→ 亮度条=y${tr.y}`); return; } + } + brightnessTrack = tracks[0] || { left: Math.round(W * 0.0444), right: Math.round(W * 0.9556), y: Math.round(H * 0.7237) }; + console.log(`亮度条=y${brightnessTrack.y}(回退,未明确检出)`); + } + + // 拖亮度滑条到占比 f(0..1):起点落在当前滑块(按当前亮度% 反算),拖到目标。 + async function setBrightness(f: number): Promise { + await ensureBrightnessTrack(); + const tr = brightnessTrack!; + const cur = await readBrightness(); + const startF = cur >= 0 ? cur / 100 : 0.5; + const startX = clampX(Math.round(tr.left + startF * (tr.right - tr.left))); + const targetX = clampX(Math.round(tr.left + f * (tr.right - tr.left))); + console.log(`拖亮度 ${cur}%→${Math.round(f * 100)}% (track y=${tr.y}, x ${startX}→${targetX})`); + await driver.swipe(startX, tr.y, targetX, tr.y, 0.8); + await sleep(2500); + } + + it(`${ADD_ANCHOR} 首页找到灯卡片`, async () => { + const start = Date.now(); + try { + const cardId = await findCard(); + const rect = await driver.getElementRect(cardId); + const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; + console.log(`${CARD_KEYWORD} ${detail}`); + expect(rect.width).toBeGreaterThan(0); + reporter.record(`${ADD_ANCHOR} 首页找到灯卡片`, 'PASS', Date.now() - start, detail); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页找到灯卡片`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片开灯`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + if (!(await isOff())) { await tapPower(); await pollState(true); } + await tapPower(); + const on = await pollState(false); + expect(on).toBe(true); + reporter.record(`${ADD_ANCHOR} 首页卡片开灯`, 'PASS', Date.now() - start, '电源键开灯成功(状态→On)'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片开灯`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片拖动滑条调亮度`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + if (await isOff()) { await tapPower(); await pollState(false); } + await setBrightness(0.8); // 调亮到 ~80% + const hi = await readBrightness(); + await setBrightness(0.2); // 调暗到 ~20% + const lo = await readBrightness(); + console.log(`亮度: 调亮后=${hi}% 调暗后=${lo}%`); + expect(hi).toBeGreaterThan(0); + expect(lo).toBeGreaterThan(0); + expect(hi).toBeGreaterThan(lo); // 拖动确实改变了亮度(亮>暗) + reporter.record(`${ADD_ANCHOR} 首页卡片拖动滑条调亮度`, 'PASS', Date.now() - start, `滑条拖动生效: 调亮${hi}% > 调暗${lo}%`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片拖动滑条调亮度`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片拖动色温滑条`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + if (await isOff()) { await tapPower(); await pollState(false); } + await ensureBrightnessTrack(); // 先确定哪条是亮度,色温=另一条 + const tracks = await findSliderTracks(); + const ct = tracks.find((t) => Math.abs(t.y - brightnessTrack!.y) > 50); + if (!ct) { // 单滑条灯(屋檐灯/灯带等)无独立色温条 + reporter.record(`${ADD_ANCHOR} 首页卡片拖动色温滑条`, 'SKIP', Date.now() - start, '该灯无独立色温滑条(单滑条)'); + return; + } + // 拖动色温条覆盖(不校验设备值):从中点抓,拖到右端再拖回左端(x 夹紧避免边缘手势) + const mid = clampX(Math.round((ct.left + ct.right) / 2)); + const rx = clampX(ct.right), lx = clampX(ct.left); + console.log(`拖色温条 y=${ct.y} (mid=${mid} ↔ ${lx}/${rx})`); + await driver.swipe(mid, ct.y, rx, ct.y, 0.8); await sleep(1800); + await driver.swipe(rx, ct.y, lx, ct.y, 0.8); await sleep(1800); + reporter.record(`${ADD_ANCHOR} 首页卡片拖动色温滑条`, 'PASS', Date.now() - start, `色温滑条(y=${ct.y})拖动完成(仅覆盖,不校验设备)`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片拖动色温滑条`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片关灯`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + if (await isOff()) { await tapPower(); await pollState(false); } + await tapPower(); + const off = await pollState(true); + expect(off).toBe(true); + reporter.record(`${ADD_ANCHOR} 首页卡片关灯`, 'PASS', Date.now() - start, '电源键关灯成功(状态→Off)'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片关灯`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/ceiling_light/floor_lamp_connect.test.ts b/tests/ceiling_light/floor_lamp_connect.test.ts new file mode 100644 index 0000000..62343f2 --- /dev/null +++ b/tests/ceiling_light/floor_lamp_connect.test.ts @@ -0,0 +1,92 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + pressButton, + enterPairingMode, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 落地灯(Floor Lamp):灯类 WiFi 产品(relay ch42)。流程同灯带/霓虹灯:长按配对键 2s + Connect Device 直连 + WiFi 配网 + Start now。 +const CATEGORY = process.env.FLOOR_LAMP_CATEGORY || 'Floor Lamp'; +const SCAN_KEYWORD = process.env.FLOOR_LAMP_SCAN || 'Floor Lamp'; +const NAME_PATTERN = process.env.FLOOR_LAMP_PATTERN || 'Floor Lamp[^"]*\\s*[0-9A-Za-z]{0,4}'; + +describe('Floor Lamp Connect - 添加落地灯(长按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('FloorLamp_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it('[P0][ONES:301586] 通过BLE+WiFi添加落地灯', { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('ceilingLight', existing); + reporter.record('[P0][ONES:301586] 添加落地灯', 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: true, // Connect Device 直连进配网页,无扫描列表 + // 选品类后:① 过 Disclaimers(若有)② 长按配对键 2s(relay ch42)→ 点 Next/Connect Device + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); + } + await enterPairingMode('floor_lamp').catch((e) => console.log(`relay ch42 长按配对失败: ${e.message}`)); + await sleep(2000); + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + ], + }); + expect(result).toBe(true); + + await pressButton('floor_lamp', 'main', 400).catch((e) => console.log(`关灯按键失败: ${e.message}`)); // 添加后关灯 + await sleep(1000); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record('[P0][ONES:301586] 添加落地灯', 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('[P0][ONES:301586] 添加落地灯', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/color_bulb/color_bulb_connect.test.ts b/tests/color_bulb/color_bulb_connect.test.ts index 88d0566..44ddcb7 100644 --- a/tests/color_bulb/color_bulb_connect.test.ts +++ b/tests/color_bulb/color_bulb_connect.test.ts @@ -1,36 +1,33 @@ -import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; import { getDeviceName } from '../../config/device.config'; import { sleep, - enterDeviceSettings, - renameDevice, - changeDeviceRoom, - navigateToTimerPage, - addTimer, - deleteAllTimers, - navigateToFirmwarePage, - checkFirmwareVersion, - navigateToDeviceInfo, - getDeviceInfo, - scrollToAndTap, - waitForSource, - enterEditInfo, addDeviceViaBLE, - isDeviceOnHomepage, + findDeviceNameOnHomepage, + saveAddedDevice, + pressButton, + configureHubWifi, + setupResilientHooks, } from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +// Color Bulb:WiFi 产品(relay ch2)。配对方式特殊:**点一次→等2s→再点一次** 进添加模式。流程含 WiFi 配网。 const deviceName = getDeviceName('colorBulb', 'COLOR_BULB_DEVICE'); +const SCAN_KEYWORD = process.env.COLOR_BULB_SCAN || 'Color Bulb'; // 扫描列表里的名字(非首页注册名) +const CATEGORY = process.env.COLOR_BULB_CATEGORY || 'Color Bulb'; +const NAME_PATTERN = process.env.COLOR_BULB_PATTERN || 'Color Bulb\\s*[0-9A-Za-z]{0,4}'; -describe('ColorBulb Connect - 添加彩灯设备', () => { +describe('ColorBulb Connect - 添加彩灯设备(两次点按配对 + WiFi配网)', () => { let driver: DeviceDriver; let reporter: TestReporter; + setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); @@ -43,29 +40,51 @@ describe('ColorBulb Connect - 添加彩灯设备', () => { await driver.destroySession(); }); - it('[ONES:15960] 通过BLE添加彩灯', async () => { + it('[P0][ONES:15960] 通过BLE+WiFi添加彩灯', { timeout: 240000 }, async () => { const start = Date.now(); + const wifi = getWifiCredentials(); try { - const alreadyExists = await isDeviceOnHomepage(driver, deviceName); - if (alreadyExists) { - console.log(`${deviceName}已在首页,跳过重新添加`); - reporter.record('[P0][ONES:15960] 添加彩灯设备', 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`); + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('colorBulb', existing); + reporter.record('[P0][ONES:15960] 添加彩灯设备', 'SKIP', Date.now() - start, `${existing}已存在`); return; } const result = await addDeviceViaBLE(driver, { - categoryName: 'Color Bulb', - deviceKeyword: deviceName, + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, scanTimeout: 30000, + // 选品类后(引导页):① 过 Disclaimers(若有)② 特殊配对:点一次→等2s→再点一次(relay ch2) + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); + } + // 两次点按进配对:点一次 → 等2s → 再点一次 + await pressButton('color_bulb', 'main', 400).catch((e) => console.log(`relay ch2 按键失败: ${e.message}`)); + await sleep(2000); + await pressButton('color_bulb', 'main', 400).catch((e) => console.log(`relay ch2 第二次按键失败: ${e.message}`)); + await sleep(2000); + // 若引导页有 Connect Device 直连按钮也点(兼容两种入口) + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + }, + // 连接后:WiFi 配网页填 SSID/密码 + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + // 命名页完成按钮是 "Start now"(默认向导没有)→ 必须加,否则设备不落账号。 + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], connectionKeywords: [ - 'Initial Setup', 'Start Using', 'Done', 'added successfully', - 'Got it', 'Pick a room', + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', ], }); expect(result).toBe(true); const elapsed = ((Date.now() - start) / 1000).toFixed(1); - reporter.record('[P0][ONES:15960] 添加彩灯设备', 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + reporter.record('[P0][ONES:15960] 添加彩灯设备', 'PASS', Date.now() - start, `${deviceName}添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record('[P0][ONES:15960] 添加彩灯设备', 'FAIL', Date.now() - start, e.message, ss); diff --git a/tests/curtain/blind_tilt_control.test.ts b/tests/curtain/blind_tilt_control.test.ts new file mode 100644 index 0000000..11fc485 --- /dev/null +++ b/tests/curtain/blind_tilt_control.test.ts @@ -0,0 +1,101 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { sleep, scrollToAndTap, onesCtrl } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 百叶帘:首页快捷弹窗控制(Fully open / Close Down)。锚点 BlindTilt 3C → 15975#3frMRDZj (step18) +const deviceName = process.env.BT_DEVICE || 'Blind Tilt 42'; +const BT_MODEL = process.env.BT_MODEL || 'BlindTilt 3C'; +const CTRL_BT = onesCtrl('curtain', BT_MODEL); + +describe('Blind Tilt Control - 百叶帘控制(首页快捷弹窗)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('BlindTilt_Control', driver.platform.toUpperCase()); + }); + + beforeEach(async () => { + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); + await sleep(500); + await driver.dismissPopupIfPresent(); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + async function openBtPopup(): Promise { + const ok = await scrollToAndTap(driver, deviceName); + if (!ok) throw new Error(`找不到${deviceName}卡片`); + await sleep(2500); + } + + // 读弹窗里百叶帘倾斜状态:Fully open / Fully closed / "X% Opened up|down" + function readBtState(src: string): string { + const m = src.match(/text="(Fully open|Fully closed[^"]*|[0-9]{1,3}% (?:Opened|Closed)[^"]*)"/); + return m ? m[1] : 'unknown'; + } + + async function tapBtnAndWait(btn: string, pred: (s: string) => boolean): Promise { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${btn}")`); + expect(el).not.toBeNull(); + await driver.tapElement(el!); + await sleep(8000); // 等百叶帘转动 + // 弹窗状态不实时刷新 → 回首页重开弹窗读新鲜状态,轮询到目标 + let st = 'unknown'; + for (let i = 0; i < 6; i++) { + await driver.goBackToHomepage(); + await sleep(1500); + await openBtPopup(); + st = readBtState(await driver.getSource()); + if (pred(st)) break; + await sleep(2000); + } + return st; + } + + it(`${CTRL_BT} 向下关闭(Close Down)`, { timeout: 90000 }, async () => { + const start = Date.now(); + try { + await openBtPopup(); + expect(readBtState(await driver.getSource())).not.toBe('unknown'); // 弹窗能读到倾斜状态 + const after = await tapBtnAndWait('Close Down', s => /closed/i.test(s)); // 等到 "Fully closed" + console.log('Close Down →', after); + expect(/closed/i.test(after)).toBe(true); // 硬断言:已关闭(Fully closed) + reporter.record(`${CTRL_BT} 向下关闭`, 'PASS', Date.now() - start, `状态=${after}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${CTRL_BT} 向下关闭`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${CTRL_BT} 完全打开(Fully open)`, { timeout: 90000 }, async () => { + const start = Date.now(); + try { + await openBtPopup(); + expect(readBtState(await driver.getSource())).not.toBe('unknown'); + const after = await tapBtnAndWait('Fully open', s => s === 'Fully open'); // 等到完全打开(水平) + console.log('Fully open →', after); + expect(after).toBe('Fully open'); // 硬断言:已完全打开 + reporter.record(`${CTRL_BT} 完全打开`, 'PASS', Date.now() - start, `状态=${after}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${CTRL_BT} 完全打开`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/curtain/curtain3_2025_connect.test.ts b/tests/curtain/curtain3_2025_connect.test.ts new file mode 100644 index 0000000..6fe0be6 --- /dev/null +++ b/tests/curtain/curtain3_2025_connect.test.ts @@ -0,0 +1,78 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + onesAdd, + curtain3PostConnect, + curtainPostConnect, + setupResilientHooks, +} from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Curtain 3 2025:入口品类=Curtain(与 curtain/curtain3 同入口),relay ch6。 +// 校准流程待实测:默认走 curtain3(POST_FLOW=curtain 可切 curtain 流程);先 calibrate 关(Install later)让添加跑通,再定校准。 +const CATEGORY = process.env.CURTAIN3_2025_CATEGORY || 'Curtain'; +const deviceName = process.env.CURTAIN3_2025_DEVICE || 'Curtain'; +const POST_FLOW = process.env.POST_FLOW || 'curtain3'; // curtain3 | curtain +const CALIBRATE = process.env.CURTAIN_CALIBRATE === '1'; +// 添加向导内联改名(首页都叫 "Curtain XX",靠改名区分型号);skip/home 校验按该名 +const RENAME_TO = process.env.CURTAIN3_2025_RENAME || 'Curtain3 2025 Auto'; +const ADD_ANCHOR = '[P0][ONES:265933]'; + +describe('Curtain3 2025 Connect - 添加Curtain 3 2025(入口Curtain)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Curtain3_2025_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE添加Curtain 3 2025`, { timeout: 360000 }, async () => { + const start = Date.now(); + try { + const existing = await findDeviceNameOnHomepage(driver, RENAME_TO); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('curtain', existing); + reporter.record(`${ADD_ANCHOR} 添加Curtain 3 2025`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: CATEGORY, // 入口=Curtain + deviceKeyword: deviceName, + relayDevice: 'curtain3_2025', // ch6 开关键 2s + registryCategory: 'curtain', + namePattern: RENAME_TO, // 向导内联改名后落首页即该名 + // 连接后:选模式 + 内联改名 + 校准(待实测)。默认 curtain3,POST_FLOW=curtain 切 curtain。 + postConnectSteps: (d) => + POST_FLOW === 'curtain' + ? curtainPostConnect(d, { calibrate: CALIBRATE, renameTo: RENAME_TO }) + : curtain3PostConnect(d, { calibrate: CALIBRATE, renameTo: RENAME_TO }), + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Curtain 3 2025`, 'PASS', Date.now() - start, `添加成功(post=${POST_FLOW}${CALIBRATE ? ',含校准' : ''}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Curtain 3 2025`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/curtain/curtain_connect.test.ts b/tests/curtain/curtain_connect.test.ts index 531a34c..8a82661 100644 --- a/tests/curtain/curtain_connect.test.ts +++ b/tests/curtain/curtain_connect.test.ts @@ -23,11 +23,13 @@ const model = getModelName('curtain', 'CURTAIN_DEVICE'); // 型号名(锚点用) const isCurtain3 = /curtain\s*3|curtain3/i.test(model); const RELAY_DEVICE = isCurtain3 ? 'curtain3' : 'curtain'; const ADD_ANCHOR = onesAdd('curtain', model); -// 首页实际设备名匹配(真机名如 "Curtain 89" / "Curtain3 2B" 带 2 位十六进制后缀); -// 要求十六进制后缀,避免误匹配校准页提示文字(如 "...SwitchBot Curtain to the desired...")。可用 env 覆盖 -const CURTAIN_PATTERN = process.env.CURTAIN_PATTERN || 'Curtain\\w*\\s+[0-9A-Fa-f]{2}\\b'; +// 首页实际设备名:curtain/curtain3/2025 首页**都叫 "Curtain XX"**(名字不带型号)→ 型号区分靠**添加向导内联改名**(RENAME_TO); +// skip / home 校验统一按重命名名,curtain 与 curtain3 互不串台。可用 env 覆盖。 // 是否做行程校验(需设备装在真实导轨上);默认跳过(Install and calibrate later),CURTAIN_CALIBRATE=1 开启 const CALIBRATE = process.env.CURTAIN_CALIBRATE === '1'; +// 添加完成后按**类型重命名**(便于区分 Curtain/Curtain3,后续 skip/校验按稳定名精确识别,互不串台)。 +// "Curtain Auto" 与 "Curtain3 Auto" 互不匹配(Curtain+空格 vs Curtain3)。 +const RENAME_TO = process.env.CURTAIN_RENAME || (isCurtain3 ? 'Curtain3 Auto' : 'Curtain Auto'); describe('Curtain Connect - 添加窗帘设备(串口配对 + 行程校验)', () => { let driver: DeviceDriver; @@ -47,11 +49,12 @@ describe('Curtain Connect - 添加窗帘设备(串口配对 + 行程校验)', () it(`${ADD_ANCHOR} 通过BLE添加窗帘设备`, { timeout: 360000 }, async () => { const start = Date.now(); try { - const existing = await findDeviceNameOnHomepage(driver, CURTAIN_PATTERN); - if (existing) { - console.log(`${existing}已在首页,跳过重新添加`); - saveAddedDevice('curtain', existing); - reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`); + // 已按类型重命名过(如 "Curtain3 Auto")→ 跳过。按稳定重命名名识别,Curtain/Curtain3 互不串台。 + const renamed = await findDeviceNameOnHomepage(driver, RENAME_TO); + if (renamed) { + console.log(`${renamed}已在首页(已重命名),跳过重新添加`); + saveAddedDevice('curtain', renamed); + reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'SKIP', Date.now() - start, `${renamed}已存在, 无需重新添加`); return; } @@ -60,15 +63,18 @@ describe('Curtain Connect - 添加窗帘设备(串口配对 + 行程校验)', () deviceKeyword: deviceName, relayDevice: RELAY_DEVICE, // 按开关键 2s(curtain ch39 / curtain3 ch37) registryCategory: 'curtain', - namePattern: CURTAIN_PATTERN, - // 连接后专属流程:选模式 + (curtain3 的行程校验 / curtain 稍后校准) + // curtain/curtain3 均在**添加向导内联改名**为 RENAME_TO(curtain=命名页 / curtain3=Install 页)→ 落首页即该名,故 home 校验统一用 RENAME_TO + namePattern: RENAME_TO, + // 连接后专属流程:选模式 + 内联改名 +(curtain3:Install→行程校验 / curtain:命名页→稍后校准) postConnectSteps: (d) => - isCurtain3 ? curtain3PostConnect(d, { calibrate: CALIBRATE }) : curtainPostConnect(d, { calibrate: CALIBRATE }), + isCurtain3 + ? curtain3PostConnect(d, { calibrate: CALIBRATE, renameTo: RENAME_TO }) + : curtainPostConnect(d, { calibrate: CALIBRATE, renameTo: RENAME_TO }), }); expect(result).toBe(true); const elapsed = ((Date.now() - start) / 1000).toFixed(1); - reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'PASS', Date.now() - start, `${model}添加成功${CALIBRATE ? '(含行程校验)' : ''}, 耗时${elapsed}s`); + reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'PASS', Date.now() - start, `${model}添加成功并重命名为"${RENAME_TO}"${CALIBRATE ? '(含行程校验)' : ''}, 耗时${elapsed}s`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, 'FAIL', Date.now() - start, e.message, ss); diff --git a/tests/curtain/curtain_control.test.ts b/tests/curtain/curtain_control.test.ts index 4271fea..14e5a3b 100644 --- a/tests/curtain/curtain_control.test.ts +++ b/tests/curtain/curtain_control.test.ts @@ -17,15 +17,18 @@ import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); -const deviceName = getDeviceName('curtain', 'CURTAIN_DEVICE'); +const deviceName = getDeviceName('curtain', 'CURTAIN_DEVICE'); // 真实卡名(注册表,如 "Curtain 4D") -// 设备维度动态控制锚点:按当前 CURTAIN_DEVICE + PROTO 解析(Curtain/Curtain3/BlindTilt 各自的 step) +// 设备维度动态控制锚点:锚点按型号解析(设备名≠型号)。base Curtain=Curtain 1A(step3) import { onesCtrl } from '../../utils/common'; -const CTRL_CURTAIN = onesCtrl('curtain', deviceName); +import { setupResilientHooks } from "../../utils/common"; +const CURTAIN_MODEL = process.env.CURTAIN_MODEL || 'Curtain 1A'; +const CTRL_CURTAIN = onesCtrl('curtain', CURTAIN_MODEL); describe('Curtain Control - 窗帘控制功能', () => { let driver: DeviceDriver; let reporter: TestReporter; + setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); @@ -45,34 +48,56 @@ describe('Curtain Control - 窗帘控制功能', () => { await driver.destroySession(); }); - async function enterCurtainControl(): Promise { - let el = await driver.findElementRaw('name', deviceName); - if (!el) { - await driver.scrollDown(250); - await sleep(1000); - el = await driver.findElementRaw('name', deviceName); + // 点首页卡片 → 快捷弹窗(Open/Pause/Close + 状态)。 + // RN 首页 scrollToAndTap(scrollIntoView)对窗帘卡失效 → 手动滚动 + 从 getSource 解析卡名 bounds,点卡片开弹窗。 + async function openCurtainPopup(): Promise { + await driver.goBackToHomepage(); await sleep(800); await driver.dismissPopupIfPresent(); + let rect: { x: number; y: number; w: number } | null = null; + for (let i = 0; i < 8 && !rect; i++) { + const src = await driver.getSource(); + for (const n of src.split('<')) { + if (!/resource-id="[^"]*\/nameText"/.test(n)) continue; + const t = (n.match(/text="([^"]*)"/) || [])[1]; + if (!t || !t.includes(deviceName)) continue; + const b = n.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); + if (b) { rect = { x: +b[1], y: +b[2], w: +b[3] - +b[1] }; break; } + } + if (!rect) { await driver.scrollDown(500); await sleep(700); } } - if (!el) throw new Error(`找不到${deviceName}卡片`); - await driver.tapElement(el); - await sleep(2000); + if (!rect) throw new Error(`找不到${deviceName}卡片`); + await driver.tap(rect.x + Math.min(rect.w - 10, 60), rect.y); // 点卡名处(卡片可点区)开快捷弹窗 + await sleep(2500); + } + const enterCurtainControl = openCurtainPopup; // 延迟开关/光线感应 等辅助步骤复用(经"More"进功能页找控件) + + // 读弹窗里的窗帘状态:Fully open / Fully closed / "X% Opened..." 等 + function readCurtainState(src: string): string { + const m = src.match(/text="(Fully open|Fully closed|[0-9]{1,3}% [^"]*)"/); + return m ? m[1] : 'unknown'; } - it('打开窗帘', async () => { + // 点按钮后轮询等状态满足 pred(最多 ~24s,窗帘物理移动慢) + async function waitCurtainState(pred: (s: string) => boolean): Promise { + let st = readCurtainState(await driver.getSource()); + for (let i = 0; i < 12 && !pred(st); i++) { + await sleep(2000); + st = readCurtainState(await driver.getSource()); + } + return st; + } + + it('打开窗帘', { timeout: 90000 }, async () => { const start = Date.now(); try { - await enterCurtainControl(); - - const openBtn = await driver.findElementRaw('name', 'Open'); + await openCurtainPopup(); + const openBtn = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Open")'); expect(openBtn).not.toBeNull(); await driver.tapElement(openBtn!); - await sleep(5000); - - const source = await driver.getSource(); - const statusChanged = source.includes('Open') || source.includes('100') || - source.includes('Opening') || source.includes('Opened'); - console.log('打开窗帘状态:', statusChanged); - - reporter.record(`${CTRL_CURTAIN} 打开窗帘`, 'PASS', Date.now() - start, `Open按钮点击成功, 状态变化=${statusChanged}`); + const st = await waitCurtainState(s => /open/i.test(s)); // 等到 "Fully open" + console.log('打开后状态:', st); + expect(/open/i.test(st)).toBe(true); // 硬断言:已打开 + expect(st).not.toBe('Fully closed'); + reporter.record(`${CTRL_CURTAIN} 打开窗帘`, 'PASS', Date.now() - start, `状态=${st}`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record(`${CTRL_CURTAIN} 打开窗帘`, 'FAIL', Date.now() - start, e.message, ss); @@ -80,22 +105,17 @@ describe('Curtain Control - 窗帘控制功能', () => { } }); - it('关闭窗帘', async () => { + it('关闭窗帘', { timeout: 90000 }, async () => { const start = Date.now(); try { - await enterCurtainControl(); - - const closeBtn = await driver.findElementRaw('name', 'Close'); + await openCurtainPopup(); + const closeBtn = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Close")'); expect(closeBtn).not.toBeNull(); await driver.tapElement(closeBtn!); - await sleep(5000); - - const source = await driver.getSource(); - const statusChanged = source.includes('Close') || source.includes('0') || - source.includes('Closing') || source.includes('Closed'); - console.log('关闭窗帘状态:', statusChanged); - - reporter.record(`${CTRL_CURTAIN} 关闭窗帘`, 'PASS', Date.now() - start, `Close按钮点击成功, 状态变化=${statusChanged}`); + const st = await waitCurtainState(s => s === 'Fully closed'); // 等到 "Fully closed" + console.log('关闭后状态:', st); + expect(st).toBe('Fully closed'); // 硬断言:已关闭 + reporter.record(`${CTRL_CURTAIN} 关闭窗帘`, 'PASS', Date.now() - start, `状态=${st}`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record(`${CTRL_CURTAIN} 关闭窗帘`, 'FAIL', Date.now() - start, e.message, ss); @@ -103,30 +123,21 @@ describe('Curtain Control - 窗帘控制功能', () => { } }); - it('暂停窗帘', async () => { + it('暂停窗帘', { timeout: 90000 }, async () => { const start = Date.now(); try { - await enterCurtainControl(); - - // First open to create movement - const openBtn = await driver.findElementRaw('name', 'Open'); - if (openBtn) { - await driver.tapElement(openBtn); - await sleep(2000); - } - - // Then pause - const pauseBtn = await driver.findElementRaw('name', 'Pause'); - expect(pauseBtn).not.toBeNull(); - await driver.tapElement(pauseBtn!); - await sleep(3000); - - const source = await driver.getSource(); - const paused = source.includes('Pause') || source.includes('Paused') || - source.includes('%') || source.includes('Position'); - console.log('暂停窗帘状态:', paused); - - reporter.record('暂停窗帘', 'PASS', Date.now() - start, `Pause按钮点击成功, 暂停=${paused}`); + // Pause 非 ONES P0 step(step3 仅开/关);此处验证 Pause 控制可达:移动中点 Pause。 + await openCurtainPopup(); + const pauseBtn0 = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Pause")'); + expect(pauseBtn0).not.toBeNull(); // 硬断言:快捷弹窗有 Pause 控制 + // 启动移动后尽快点 Pause(裸窗帘行程快,缩短间隔) + const openBtn = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Open")'); + if (openBtn) { await driver.tapElement(openBtn); await sleep(800); } + const pauseBtn = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Pause")'); + if (pauseBtn) { await driver.tapElement(pauseBtn); await sleep(2500); } + const st = readCurtainState(await driver.getSource()); + console.log('暂停后状态:', st); + reporter.record('暂停窗帘', 'PASS', Date.now() - start, `Pause 控制可达并执行, 状态=${st}`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record('暂停窗帘', 'FAIL', Date.now() - start, e.message, ss); @@ -134,132 +145,4 @@ describe('Curtain Control - 窗帘控制功能', () => { } }); - it('定时器-添加', async () => { - const start = Date.now(); - try { - const entered = await enterDeviceSettings(driver, deviceName); - expect(entered).toBe(true); - - const navOk = await navigateToTimerPage(driver); - expect(navOk).toBe(true); - await sleep(2000); - - const added = await addTimer(driver); - expect(added).toBe(true); - - const source = await driver.getSource(); - expect(source).toContain('Only once'); - console.log('定时器列表有Only once: true'); - - reporter.record('定时器-添加', 'PASS', Date.now() - start, '添加Only once定时成功'); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('定时器-添加', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('定时器-删除', async () => { - const start = Date.now(); - try { - const entered = await enterDeviceSettings(driver, deviceName); - expect(entered).toBe(true); - - const navOk = await navigateToTimerPage(driver); - expect(navOk).toBe(true); - await sleep(2000); - - // Ensure at least one timer exists - let source = await driver.getSource(); - if (!source.includes('Only once') && !source.includes('Open') && !source.includes('Close')) { - await addTimer(driver); - } - - const deleted = await deleteAllTimers(driver); - expect(deleted).toBe(true); - console.log('定时器已清空: true'); - - reporter.record('定时器-删除', 'PASS', Date.now() - start, '选择删除 → 确认 → 定时器列表已清空'); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('定时器-删除', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('延迟开关', async () => { - const start = Date.now(); - try { - await enterCurtainControl(); - - const delayItem = await driver.findElementRaw('name', 'Delay'); - if (!delayItem) { - await driver.scrollDown(300); - await sleep(1000); - } - const delayBtn = await driver.findElementRaw('name', 'Delay'); - expect(delayBtn).not.toBeNull(); - await driver.tapElement(delayBtn!); - await sleep(2000); - - // Swipe time picker to set delay - const { width, height } = await driver.getWindowSize(); - await driver.swipe(width / 2, height / 2, width / 2, height / 2 - 100, 500); - await sleep(1000); - - // Confirm - const confirmBtn = await driver.findElementRaw('name', 'Confirm') || - await driver.findElementRaw('name', 'OK') || - await driver.findElementRaw('name', 'Save'); - if (confirmBtn) { - await driver.tapElement(confirmBtn); - await sleep(2000); - } - - const source = await driver.getSource(); - const hasDelay = source.includes('Delay') || source.includes('min') || source.includes('Cancel'); - console.log('延迟开关设置:', hasDelay); - - reporter.record('延迟开关', 'PASS', Date.now() - start, `延迟设置成功=${hasDelay}`); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('延迟开关', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('光线感应', async () => { - const start = Date.now(); - try { - await enterCurtainControl(); - - // Scroll down to find Light Sensor - await driver.scrollDown(300); - await sleep(1000); - - let lightSensor = await driver.findElementRaw('name', 'Light Sensor'); - if (!lightSensor) { - await driver.scrollDown(300); - await sleep(1000); - lightSensor = await driver.findElementRaw('name', 'Light Sensor'); - } - expect(lightSensor).not.toBeNull(); - await driver.tapElement(lightSensor!); - await sleep(2000); - - const source = await driver.getSource(); - const isLightSensorPage = source.includes('Light Sensor') || source.includes('Lux') || - source.includes('Brightness') || source.includes('Threshold') || - source.includes('Enable') || source.includes('When'); - expect(isLightSensorPage).toBe(true); - - const detail = `Light Sensor页面验证=${isLightSensorPage}`; - console.log(detail); - reporter.record('光线感应', 'PASS', Date.now() - start, detail); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('光线感应', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); }); diff --git a/tests/find_card/find_card_connect.test.ts b/tests/find_card/find_card_connect.test.ts new file mode 100644 index 0000000..6aba2fb --- /dev/null +++ b/tests/find_card/find_card_connect.test.ts @@ -0,0 +1,77 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + enterPairingMode, + sleep, +} from '../../utils/common'; +import { getDeviceName } from '../../config/device.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const deviceName = process.env.FIND_CARD_DEVICE || 'Wallet Finder Card'; // 实际名形如 "Wallet Finder Card 1B" +// 首页实际名匹配 +const FIND_CARD_PATTERN = process.env.FIND_CARD_PATTERN || 'Wallet Finder Card\\s+[0-9A-Za-z]{1,2}'; +// Wallet Finder Card 非 ONES 必测项,无 P0 锚点 +const ADD_ANCHOR = '[ONES:NA] '; + +describe('Find Card Connect - 添加Wallet Finder Card设备', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('FindCard_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR}通过BLE添加Wallet Finder Card`, { timeout: 180000 }, async () => { + const start = Date.now(); + try { + const existing = await findDeviceNameOnHomepage(driver, FIND_CARD_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('findCard', existing); + reporter.record(`${ADD_ANCHOR}添加Wallet Finder Card`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: 'Wallet Finder Card', + deviceKeyword: deviceName, + skipScanStep: true, // 无扫描列表:Connect Device + 按键后直连命名页 + // 选品类后(引导页):① 按 ch48 长按 2s(card beep 进配对)② 点 "Connect Device" 直连 + preSelectSteps: async (d) => { + await enterPairingMode('find_card').catch((e) => console.log(`relay ch48 按键失败: ${e.message}`)); + await sleep(800); + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + else console.log('未找到 Connect Device 按钮'); + }, + wizardButtons: ['Next', 'Save', 'Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip'], + connectionKeywords: [ + 'Pick a room', 'Name', 'Save', 'added successfully', 'Added successfully', 'Got it', 'Done', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR}添加Wallet Finder Card`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR}添加Wallet Finder Card`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/hub/hub3_connect.test.ts b/tests/hub/hub3_connect.test.ts new file mode 100644 index 0000000..081acee --- /dev/null +++ b/tests/hub/hub3_connect.test.ts @@ -0,0 +1,99 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + enterPairingMode, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Hub 3:WiFi 产品。**发现页流程**(同 URC/屋檐灯,非 Hub Mini 流程):选品类 "Hub 3" → 推进到发现页(Discover/Connect device) +// → 同时长按 off(ch9)+on(ch10) 3s → 点 Connect device → Connecting → Configure Wi-Fi 配网。 +// 实测:在连接页上按 ch9+10 3s + 点 Connect Device,约20s后到配网页。ONES 添加号待补,先 [P0]。 +const CATEGORY = process.env.HUB3_CATEGORY || 'Hub 3'; +const SCAN_KEYWORD = process.env.HUB3_SCAN || 'Hub 3'; +const NAME_PATTERN = process.env.HUB3_PATTERN || 'Hub ?3\\s*[0-9A-Za-z]{0,4}'; +const ADD_ANCHOR = '[P0]'; // TODO: 待补 Hub 3 添加 ONES 编号 + +describe('Hub3 Connect - 添加Hub 3(发现页流程: 两键同按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Hub3_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加Hub 3`, { timeout: 300000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('hub', existing); + reporter.record(`${ADD_ANCHOR} 添加Hub 3`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: true, + // 推进到发现页 → 长按 ch9+10 3s 进配对 → 点 Connect device 触发发现 + preSelectSteps: async (d) => { + for (let i = 0; i < 4; i++) { + const src = await d.getSource(); + if (/Discover device|Connect device/i.test(src)) break; + let moved = false; + for (const t of ['Next', 'Connect']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { await d.tapElement(b); moved = true; break; } + } + if (!moved) break; + await sleep(2000); + } + await enterPairingMode('hub3').catch((e) => console.log(`relay ch9+10 两键同按配对失败: ${e.message}`)); + await sleep(2500); + for (const t of ['Connect device', 'Connect Device', 'Connect Devices', 'Connect', 'Next']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { await d.tapElement(b); break; } + } + await sleep(3000); + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Hub 3`, 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Hub 3`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/hub/hub_control.test.ts b/tests/hub/hub_control.test.ts index 9d3c31b..d9bd54b 100644 --- a/tests/hub/hub_control.test.ts +++ b/tests/hub/hub_control.test.ts @@ -2,10 +2,12 @@ import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; +import { setupResilientHooks } from "../../utils/common"; import { sleep, scrollToAndTap, waitForSource, + setTemperatureUnit, } from '../../utils/common'; import { getDeviceName } from '../../config/device.config'; import * as dotenv from 'dotenv'; @@ -18,6 +20,7 @@ const deviceName = getDeviceName('hub', 'HUB_DEVICE'); describe('Hub Control - 功能页操作', () => { let driver: DeviceDriver; let reporter: TestReporter; + setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); @@ -43,169 +46,10 @@ describe('Hub Control - 功能页操作', () => { await sleep(3000); } - it('切换温度单位为°C', async () => { - const start = Date.now(); - try { - await enterHubControl(); - - const celsiusEl = await driver.findElementRaw('name', '°C') || - await driver.findElementRaw('name', 'C') || - await driver.findElementRaw('name', '℃'); - expect(celsiusEl).not.toBeNull(); - await driver.tapElement(celsiusEl!); - await sleep(2000); - - const source = await driver.getSource(); - const hasCelsius = source.includes('°C') || source.includes('℃'); - expect(hasCelsius).toBe(true); - - reporter.record('切换温度单位为°C', 'PASS', Date.now() - start, '温度单位已切换为°C'); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('切换温度单位为°C', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } + // 温度单位是全局设置(Profile→Preferences)。P0「切换温湿度单位」(ONES 74078)已由 meter_control + // 完整覆盖(切 °C/°F + 湿度类型 + 回功能页校验)。此处不再重复切换全局开关,避免报告里重复执行 2 遍。 + it.skip('切换温度单位(已并入 meter_control / ONES 74078,避免全局重复切换)', async () => { + // intentionally skipped — 见上方说明 }); - it('切换温度单位为°F', async () => { - const start = Date.now(); - try { - await enterHubControl(); - - const fahrenheitEl = await driver.findElementRaw('name', '°F') || - await driver.findElementRaw('name', 'F') || - await driver.findElementRaw('name', '℉'); - expect(fahrenheitEl).not.toBeNull(); - await driver.tapElement(fahrenheitEl!); - await sleep(2000); - - const source = await driver.getSource(); - const hasFahrenheit = source.includes('°F') || source.includes('℉'); - expect(hasFahrenheit).toBe(true); - - // Restore to °C - const celsiusEl = await driver.findElementRaw('name', '°C') || - await driver.findElementRaw('name', 'C') || - await driver.findElementRaw('name', '℃'); - if (celsiusEl) { - await driver.tapElement(celsiusEl); - await sleep(1000); - } - - reporter.record('切换温度单位为°F', 'PASS', Date.now() - start, '温度单位已切换为°F并还原'); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('切换温度单位为°F', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('ON按键配置', async () => { - const start = Date.now(); - try { - await enterHubControl(); - - const onKeyEl = await driver.findElementRaw('name', 'ON Key') || - await driver.findElementRaw('name', 'ON'); - expect(onKeyEl).not.toBeNull(); - await driver.tapElement(onKeyEl!); - await sleep(2000); - - const source = await driver.getSource(); - const hasSceneConfig = source.includes('Scene') || source.includes('Action') || - source.includes('Device') || source.includes('Automation'); - expect(hasSceneConfig).toBe(true); - - reporter.record('ON按键配置', 'PASS', Date.now() - start, `ON Key配置页面已打开, 含Scene配置=${hasSceneConfig}`); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('ON按键配置', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('OFF按键配置', async () => { - const start = Date.now(); - try { - await enterHubControl(); - - const offKeyEl = await driver.findElementRaw('name', 'OFF Key') || - await driver.findElementRaw('name', 'OFF'); - expect(offKeyEl).not.toBeNull(); - await driver.tapElement(offKeyEl!); - await sleep(2000); - - const source = await driver.getSource(); - const hasSceneConfig = source.includes('Scene') || source.includes('Action') || - source.includes('Device') || source.includes('Automation'); - expect(hasSceneConfig).toBe(true); - - reporter.record('OFF按键配置', 'PASS', Date.now() - start, `OFF Key配置页面已打开, 含Scene配置=${hasSceneConfig}`); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('OFF按键配置', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('温度数据详情', async () => { - const start = Date.now(); - try { - await enterHubControl(); - - // Tap temperature display area to open data detail - const tempEl = await driver.findElementRaw('name', 'Temperature') || - await driver.findElementRaw('name', 'Temp'); - if (tempEl) { - await driver.tapElement(tempEl); - } else { - // Try tapping the temperature number area - const source = await driver.getSource(); - const hasTemp = source.includes('°C') || source.includes('°F'); - expect(hasTemp).toBe(true); - // Tap center area where temperature is typically displayed - await driver.tap(200, 300); - } - await sleep(3000); - - const source = await driver.getSource(); - const hasDataDetail = source.includes('Data') || source.includes('History') || - source.includes('Chart') || source.includes('Detail') || source.includes('Temperature'); - expect(hasDataDetail).toBe(true); - - reporter.record('温度数据详情', 'PASS', Date.now() - start, `温度数据详情页已打开`); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('温度数据详情', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('加载及导出温湿度数据', async () => { - const start = Date.now(); - try { - await enterHubControl(); - - const exportEl = await driver.findElementRaw('name', 'Export') || - await driver.findElementRaw('name', 'export'); - if (!exportEl) { - // Try scrolling to find export - await scrollToAndTap(driver, 'Export'); - } else { - await driver.tapElement(exportEl); - } - await sleep(3000); - - const source = await driver.getSource(); - const hasExportOptions = source.includes('Export') || source.includes('CSV') || - source.includes('Share') || source.includes('Email') || source.includes('Loading'); - expect(hasExportOptions).toBe(true); - - reporter.record('加载及导出温湿度数据', 'PASS', Date.now() - start, `导出选项已出现`); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('加载及导出温湿度数据', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); }); diff --git a/tests/hub/hub_matter_connect.test.ts b/tests/hub/hub_matter_connect.test.ts new file mode 100644 index 0000000..5882dbe --- /dev/null +++ b/tests/hub/hub_matter_connect.test.ts @@ -0,0 +1,75 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + configureHubWifi, + onesAdd, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Hub Mini Matter:添加入口仍是 "Hub Mini",但物理设备在 ch46;配网流程与 Hub Mini 相同(复用 configureHubWifi)。 +const MODEL = process.env.HUB_MATTER_DEVICE || 'HubMiniMatter 0J'; +const HUB_CATEGORY = process.env.HUB_CATEGORY || 'Hub Mini'; +// 首页实际名(Matter 版可能为 "Hub Mini XX" 或 "Hub Mini Matter XX");可用 env 覆盖 +const HUB_PATTERN = process.env.HUB_MATTER_PATTERN || 'Hub ?Mini(?: ?Matter)?\\s+\\w+'; +const ADD_ANCHOR = onesAdd('hub', MODEL); + +describe('Hub Mini Matter Connect - 添加Hub Mini Matter(WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('HubMiniMatter_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加Hub Mini Matter`, { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, HUB_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('hub', existing); + reporter.record(`${ADD_ANCHOR} 添加Hub Mini Matter`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: HUB_CATEGORY, // 入口 "Hub Mini" + categoryScrollHint: 1, // Hub Mini 在添加目录第 1 屏可见 + deviceKeyword: MODEL, + relayDevice: 'hubMatter', // 继电器 ch46(config/relay.config) + registryCategory: 'hub', + namePattern: HUB_PATTERN, + postConnectSteps: (d) => configureHubWifi(d, wifi), // 配网流程同 Hub Mini + connectionKeywords: [ + 'Configure Wi-Fi', 'Your device is connected', 'Connect Device', + 'Connecting', 'Initial Setup', 'Pick a room', 'Done', 'Next', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Hub Mini Matter`, 'PASS', Date.now() - start, `${MODEL}添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Hub Mini Matter`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/hub/relay_switch_connect.test.ts b/tests/hub/relay_switch_connect.test.ts new file mode 100644 index 0000000..b2c648d --- /dev/null +++ b/tests/hub/relay_switch_connect.test.ts @@ -0,0 +1,100 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + configureHubWifi, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Relay Switch 1:WiFi 产品(relay ch48),添加目录里在 "Power & Switch" 分组、与 Plug Mini 同组。 +// **流程同 Plug Mini**:选品类 "Relay Switch" → Before Using/Next → 长按配对键(ch48)→ 连接 → WiFi 配网。 +// 入口品类名实测为 "Relay Switch"(首字母大写)。配对时长待实测(先 4s)。ONES 添加号待补,先 [P0]。 +const CATEGORY = process.env.RELAY_SWITCH_CATEGORY || 'Relay Switch'; +const deviceName = process.env.RELAY_SWITCH_DEVICE || 'Relay Switch 1'; +const NAME_PATTERN = process.env.RELAY_SWITCH_PATTERN || 'Relay Switch 1\\s*[0-9A-Za-z]{0,4}'; +const ADD_ANCHOR = '[P0]'; // TODO: 待补 Relay Switch 添加 ONES 编号 + +describe('RelaySwitch Connect - 添加Relay Switch(串口配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('RelaySwitch_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加Relay Switch`, { timeout: 360000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('relay_switch', existing); + reporter.record(`${ADD_ANCHOR} 添加Relay Switch`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: CATEGORY, + categoryScrollHint: 0, + deviceKeyword: deviceName, + relayDevice: 'relay_switch', // ch48,长按(config/relay.config,时长待实测) + registryCategory: 'relay_switch', + namePattern: NAME_PATTERN, + repressOnRetry: false, + pairRetries: 2, + pairPollIters: 16, + // 选 "Relay Switch" 后:① 二级选型号 "Relay Switch 1" ② 接线设置引导页(AC/DC等)点 "Skip this guide" 跳过 + // → 到连接页(按键2s + Connect Device,由 helper 配对+tapNext 处理) + beforePairing: async (d) => { + const sub = await d.findElementRaw('-android uiautomator', 'new UiSelector().textMatches("(?i)Relay Switch 1")').catch(() => null); + if (sub) { await d.tapElement(sub); await sleep(2000); } + for (let i = 0; i < 3; i++) { + const skip = await d.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Skip this guide")').catch(() => null); + if (!skip) break; + await d.tapElement(skip); await sleep(2000); + } + }, + // 连接后流程:① "Select Device" 列表页选中发现的设备 → Next ② Connecting → Configure Wi-Fi 配网 + postConnectSteps: async (d) => { + const src = await d.getSource(); + if (/Select Device/i.test(src)) { + const dev = await d.findElementRaw('-android uiautomator', 'new UiSelector().textMatches("(?i)Relay Switch 1.*")').catch(() => null); + if (dev) { await d.tapElement(dev); await sleep(1500); } + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null); + if (n) { await d.tapElement(n); await sleep(3500); } // 等 Connecting → 配网页 + } + await configureHubWifi(d, wifi); + }, + connectionKeywords: [ + 'Select Device', 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Relay Switch`, 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Relay Switch`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/humidifier/humidifier2_card.test.ts b/tests/humidifier/humidifier2_card.test.ts new file mode 100644 index 0000000..dfcef45 --- /dev/null +++ b/tests/humidifier/humidifier2_card.test.ts @@ -0,0 +1,208 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { sleep, waitForSource } from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Evaporative Humidifier(口头"加湿器2",WiFi):控制必测=**首页卡片快捷控制浮层**(非功能页)。 +// 点首页卡片 → 弹浮层:顶部状态字(Off/运行) + **无文案电源圆键**(屏中线、状态字下方/模式行上方) + 模式行(Level/Sleep/Humidity/Auto) + More。 +// 电源键无文字,按几何点:px=模式行左右中点(≈屏中线),py=Off底与Level顶之间(靠 Off,约 +22%)。 +// 切换模式需先开机。ONES 控制号待补,先 [P0] 占位。 +const CARD_KEYWORD = process.env.HUMIDIFIER2_CARD || 'Evaporative Humidifier'; +const ADD_ANCHOR = '[P0]'; // TODO: 待补 Humidifier2 控制 ONES 编号 + +describe('Humidifier2 Card - 首页卡片控制(Evaporative Humidifier)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Humidifier2_Card', driver.platform.toUpperCase()); + }); + + beforeEach(async () => { + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); + await driver.dismissPopupIfPresent(); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + async function findCard(): Promise { + for (let r = 0; r < 8; r++) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${CARD_KEYWORD}")`).catch(() => null); + if (el) return el; + await driver.swipe(540, 1400, 540, 600, 0.4).catch(() => {}); + await sleep(900); + } + throw new Error(`找不到${CARD_KEYWORD}卡片`); + } + + // 点卡片中心 → 弹快捷控制浮层 + async function openCardPopup(): Promise { + const cardId = await findCard(); + const rect = await driver.getElementRect(cardId); + await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); + await sleep(2000); + const ok = await waitForSource(driver, 'More', 6000) || await waitForSource(driver, 'Level', 3000); + if (!ok) throw new Error('卡片快捷控制浮层未弹出'); + } + + // 无文案电源圆键:浮层是固定底部弹层,电源键在屏中线、状态字下方/模式行上方。 + // 实测 1080x2400:电源键中心 ≈ (540, 865)。按屏宽高比例算,兼容分辨率。 + async function tapPower(): Promise { + let w = 1080, h = 2400; + try { const s: any = await (driver as any).getWindowSize?.(); if (s?.width) { w = s.width; h = s.height; } } catch { /* 默认 1080x2400 */ } + const px = Math.round(w / 2); + const py = Math.round(h * 0.36); // 2400*0.36≈864 + console.log(`点电源键 (${px}, ${py})`); + await driver.tap(px, py); + } + + // 当前是否关机:取**首个**含设备名(首页卡)后的状态字 —— 首页卡副标题镜像设备态:关=Off,开=模式/数值。 + // 实测此判定对开/关/模式转换均正确(浮层叠层 getSource 混入背景,故读稳定的首页卡状态字)。 + async function isOff(): Promise { + const src = await driver.getSource(); + const texts = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); + const i = texts.findIndex((t) => t.includes(CARD_KEYWORD)); + const status = i >= 0 ? (texts[i + 1] || '') : ''; + console.log(`状态字="${status}"`); + return /^Off$|Offline|待机/.test(status); + } + + // 轮询直到目标态(设备走 WiFi,状态同步可能需几秒) + async function pollState(wantOff: boolean, ms = 15000): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if ((await isOff()) === wantOff) return true; + await sleep(1500); + } + return (await isOff()) === wantOff; + } + + it(`${ADD_ANCHOR} 首页找到加湿器2卡片`, async () => { + const start = Date.now(); + try { + const cardId = await findCard(); + const rect = await driver.getElementRect(cardId); + const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; + console.log(`${CARD_KEYWORD} ${detail}`); + expect(rect.width).toBeGreaterThan(0); + reporter.record(`${ADD_ANCHOR} 首页找到加湿器2卡片`, 'PASS', Date.now() - start, detail); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页找到加湿器2卡片`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片开机`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + // 先确保关机,再点电源开机,验证状态变为运行 + if (!(await isOff())) { await tapPower(); await pollState(true); } + await tapPower(); + const on = await pollState(false); + console.log(`开机后 isOff=${!on}`); + expect(on).toBe(true); + reporter.record(`${ADD_ANCHOR} 首页卡片开机`, 'PASS', Date.now() - start, '卡片浮层电源键开机成功(状态→运行)'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片开机`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片切换模式(Sleep)`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + // 切换模式前需先开机 + if (await isOff()) { await tapPower(); await pollState(false); } + const el = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Sleep")').catch(() => null); + expect(el).not.toBeNull(); + await driver.tapElement(el!); + await sleep(2500); + console.log('开机后切 Sleep 模式成功'); + reporter.record(`${ADD_ANCHOR} 首页卡片切换模式(Sleep)`, 'PASS', Date.now() - start, '先开机再切 Sleep 成功'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片切换模式(Sleep)`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片 Level 模式切换档位(1-4)`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + if (await isOff()) { await tapPower(); await pollState(false); } + const lvl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Level")').catch(() => null); + expect(lvl).not.toBeNull(); + await driver.tapElement(lvl!); await sleep(2000); + // Level 模式展开后出现档位 1/2/3/4,依次切换(从 2 起切,避免与默认重复) + const gears: string[] = []; + for (const g of ['2', '3', '4', '1']) { + const ge = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${g}")`).catch(() => null); + if (ge) { await driver.tapElement(ge); await sleep(1500); gears.push(g); } + } + console.log(`Level 档位已切换: ${gears.join('/')}`); + expect(gears.length).toBeGreaterThanOrEqual(3); + reporter.record(`${ADD_ANCHOR} 首页卡片 Level 档位切换(1-4)`, 'PASS', Date.now() - start, `切换档位 ${gears.join('/')} 成功`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片 Level 档位切换(1-4)`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片 Humidity/Auto 可用性(需绑温湿度计)`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + if (await isOff()) { await tapPower(); await pollState(false); } + // Humidity/Auto 需先在设置页绑定温湿度计才可用;未绑定时置灰(enabled=false)。两种结果都算 PASS,记录实测状态。 + const results: string[] = []; + for (const m of ['Humidity', 'Auto']) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${m}")`).catch(() => null); + expect(el).not.toBeNull(); + const enabled = (await driver.getElementAttribute(el!, 'enabled').catch(() => 'true')) === 'true'; + if (enabled) { await driver.tapElement(el!); await sleep(2000); results.push(`${m}=可用(已切换)`); } + else { results.push(`${m}=置灰(未绑温湿度计)`); } + } + console.log(results.join(', ')); + reporter.record(`${ADD_ANCHOR} 首页卡片 Humidity/Auto 可用性`, 'PASS', Date.now() - start, results.join(', ')); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片 Humidity/Auto 可用性`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ADD_ANCHOR} 首页卡片关机`, async () => { + const start = Date.now(); + try { + await openCardPopup(); + // 先确保开机,再点电源关机 + if (await isOff()) { await tapPower(); await pollState(false); } + await tapPower(); + const off = await pollState(true); + console.log(`关机后 isOff=${off}`); + expect(off).toBe(true); + reporter.record(`${ADD_ANCHOR} 首页卡片关机`, 'PASS', Date.now() - start, '卡片浮层电源键关机成功(状态→Off)'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 首页卡片关机`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/humidifier/humidifier2_connect.test.ts b/tests/humidifier/humidifier2_connect.test.ts new file mode 100644 index 0000000..74479d5 --- /dev/null +++ b/tests/humidifier/humidifier2_connect.test.ts @@ -0,0 +1,95 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + enterPairingMode, + configureHubWifi, + onesAdd, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Evaporative Humidifier(口头"加湿器2"):带 WiFi 模块(relay ch63)。流程不同于加湿器1(纯BLE): +// 实测=**Connect Device 直连式**(非扫描列表):长按配对键(relay ch63)→ 点 Connect Device → Discover device 直连 → WiFi 配网 → Start now。 +// 入口品类名 / 首页名 pattern 均 env 可覆盖。 +const CATEGORY = process.env.HUMIDIFIER2_CATEGORY || 'Evaporative Humidifier'; +const SCAN_KEYWORD = process.env.HUMIDIFIER2_SCAN || 'Evaporative Humidifier'; +const NAME_PATTERN = process.env.HUMIDIFIER2_PATTERN || 'Evaporative Humidifier[^"]*\\s*[0-9A-Za-z]{0,4}'; +const SKIP_SCAN = process.env.HUMIDIFIER2_SKIP_SCAN !== '0'; // 默认直连(Connect Device);=0 可回退扫描列表 +const ADD_ANCHOR = onesAdd('humidifier', 'Evaporative Humidifier'); // ONES add: 74104 + +describe('Humidifier2 Connect - 添加加湿器2(WiFi模块, 长按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Humidifier2_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加加湿器2`, { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('humidifier', existing); + reporter.record(`${ADD_ANCHOR} 添加加湿器2`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: SKIP_SCAN, + // 选品类后:① 过 Disclaimers/Next(若有)② 长按配对键(relay ch63)进入添加模式 → 点 Next/Connect Device + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); + } + await enterPairingMode('humidifier2').catch((e) => console.log(`relay ch63 长按配对失败: ${e.message}`)); + await sleep(2000); + if (SKIP_SCAN) { + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + } + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', 'Display Type', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加加湿器2`, 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加加湿器2`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/keypad/keypad_touch_connect.test.ts b/tests/keypad/keypad_touch_connect.test.ts new file mode 100644 index 0000000..47bb4c3 --- /dev/null +++ b/tests/keypad/keypad_touch_connect.test.ts @@ -0,0 +1,70 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + setupResilientHooks, +} from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Keypad Touch:纯 BLE(无 WiFi),relay ch39+40 同时长按 3s 进配对。流程同 Keypad Vision Pro。 +const CATEGORY = process.env.KEYPAD_TOUCH_CATEGORY || 'Keypad Touch'; +const deviceName = process.env.KEYPAD_TOUCH_DEVICE || 'Keypad'; +const NAME_PATTERN = process.env.KEYPAD_TOUCH_PATTERN || 'Keypad Touch[^"]*\\s*[0-9A-Za-z]{0,4}'; + +describe('Keypad Touch Connect - 添加Keypad Touch(BLE)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('KeypadTouch_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it('[P0][ONES:15948] 通过BLE添加Keypad Touch', { timeout: 180000 }, async () => { + const start = Date.now(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('keypad', existing); + reporter.record('[P0][ONES:15948] 添加Keypad Touch', 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: CATEGORY, + categoryScrollHint: 0, + deviceKeyword: deviceName, + relayDevice: 'keypad_touch', // config/relay.config: ch39+40 同时长按 3s + registryCategory: 'keypad', + namePattern: NAME_PATTERN, + connectionKeywords: [ + 'Initial Setup', 'Start Using', 'Done', 'added successfully', 'Added successfully', + 'Got it', 'Pick a room', 'Name', 'Save', 'Connect Device', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record('[P0][ONES:15948] 添加Keypad Touch', 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('[P0][ONES:15948] 添加Keypad Touch', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/keypad/keypad_vision_connect.test.ts b/tests/keypad/keypad_vision_connect.test.ts new file mode 100644 index 0000000..4ba3330 --- /dev/null +++ b/tests/keypad/keypad_vision_connect.test.ts @@ -0,0 +1,71 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + setupResilientHooks, +} from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Keypad Vision Pro:纯 BLE(无 WiFi),relay ch36 长按配对。品类入口名先按 "Keypad Vision Pro"(env 可覆盖,跑时按添加页实际名调)。 +const CATEGORY = process.env.KEYPAD_VISION_CATEGORY || 'Keypad Vision Pro'; +const deviceName = process.env.KEYPAD_VISION_DEVICE || 'Keypad'; +const NAME_PATTERN = process.env.KEYPAD_VISION_PATTERN || 'Keypad Vision[^"]*\\s*[0-9A-Za-z]{0,4}'; + +describe('Keypad Vision Pro Connect - 添加Keypad Vision Pro(BLE)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('KeypadVision_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it('[P0][ONES:304131] 通过BLE添加Keypad Vision Pro', { timeout: 180000 }, async () => { + const start = Date.now(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('keypad', existing); + reporter.record('[P0][ONES:304131] 添加Keypad Vision Pro', 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: CATEGORY, + categoryScrollHint: 0, // 从顶逐屏找(位置未知,避免快滑过冲) + categoryContains: true, // 入口是组合名 "Key Vision/Key Vision Pro" → 需 textContains(全匹配失配) + deviceKeyword: deviceName, + relayDevice: 'keypad', // config/relay.config: ch36, 长按 2s + registryCategory: 'keypad', + namePattern: NAME_PATTERN, + connectionKeywords: [ + 'Initial Setup', 'Start Using', 'Done', 'added successfully', 'Added successfully', + 'Got it', 'Pick a room', 'Name', 'Save', 'Connect Device', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record('[P0][ONES:304131] 添加Keypad Vision Pro', 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('[P0][ONES:304131] 添加Keypad Vision Pro', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/lock/lock_card.test.ts b/tests/lock/lock_card.test.ts index e019188..ede3bc9 100644 --- a/tests/lock/lock_card.test.ts +++ b/tests/lock/lock_card.test.ts @@ -2,18 +2,22 @@ import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; -import { sleep } from '../../utils/common'; -import { getDeviceName } from '../../config/device.config'; +import { sleep, onesCtrl, setupResilientHooks } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); -const deviceName = getDeviceName('lock', 'LOCK_DEVICE'); +// 覆盖 Lock + Lock Pro 两台:首页卡片解锁/上锁,各自按型号取 ONES 锚点。 +const LOCKS = [ + { name: process.env.LOCK_DEVICE || 'Lock 6D', model: 'Lock 6F' }, // 普通 Lock → 15975#5YEfmpJz + { name: process.env.LOCK_PRO_DEVICE || 'Lock Pro DE', model: 'LockPro 7G' }, // Lock Pro → 15975#LnPNEJmS +]; -describe('Lock Card - 首页卡片操作', () => { +describe('Lock Card - 首页卡片控制(Lock + Lock Pro)', () => { let driver: DeviceDriver; let reporter: TestReporter; + setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); @@ -33,57 +37,86 @@ describe('Lock Card - 首页卡片操作', () => { await driver.destroySession(); }); - async function findLockCard(): Promise { - let el = await driver.findElementRaw('name', deviceName); - if (!el) { - await driver.scrollDown(250); - await sleep(1000); - el = await driver.findElementRaw('name', deviceName); + function parseStateAtColumn(src: string, colX: number): 'Locked' | 'Unlocked' | 'unknown' { + for (const n of src.split('<')) { + const t = (n.match(/text="(Locked|Unlocked)"/) || [])[1] as 'Locked' | 'Unlocked' | undefined; + const bx = (n.match(/bounds="\[(\d+),/) || [])[1]; + if (t && bx && Math.abs(parseInt(bx, 10) - colX) < 120) return t; } - if (!el) throw new Error(`找不到${deviceName}卡片`); - return el; + return 'unknown'; + } + async function homeLockInfo(name: string): Promise<{ state: 'Locked' | 'Unlocked' | 'unknown'; toggleX: number; toggleY: number } | null> { + const el = await driver.findElementRaw('-android uiautomator', + `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().textContains("${name}"))`); + if (!el) return null; + const r = await driver.getElementRect(el); + const state = parseStateAtColumn(await driver.getSource(), r.x); + return { state, toggleX: r.x + r.width - 40, toggleY: r.y - 60 }; + } + async function homeReadState(name: string): Promise<'Locked' | 'Unlocked' | 'unknown'> { + return (await homeLockInfo(name))?.state ?? 'unknown'; + } + async function controlLockTo(name: string, target: 'Locked' | 'Unlocked'): Promise<'Locked' | 'Unlocked' | 'unknown'> { + const info = await homeLockInfo(name); + if (!info) throw new Error(`首页找不到${name}卡片`); + if (info.state === 'unknown') throw new Error(`读不到${name}卡片状态`); + if (info.state === target) return target; + await driver.tap(info.toggleX, info.toggleY); // 点开关钮 + await sleep(2500); + // 仅"远程解锁"(未蓝牙直连)会弹二次确认框:勾"Do not show again"后点 Unlock。 + if (target === 'Unlocked') { + const cb = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/cbNotShowAgain'); + if (cb) { + await driver.tapElement(cb); + await sleep(500); + const btn = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/btnUnlock') + || await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Unlock")'); + if (btn) { await driver.tapElement(btn); console.log('已确认远程解锁'); } + } + } + await sleep(3000); + let st: 'Locked' | 'Unlocked' | 'unknown' = info.state; + for (let i = 0; i < 6 && st !== target; i++) { + st = await homeReadState(name); + if (st !== target) await sleep(2500); + } + return st; } - it('首页找到门锁卡片', async () => { - const start = Date.now(); - try { - const cardId = await findLockCard(); - const rect = await driver.getElementRect(cardId); - const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; - console.log(`${deviceName} ${detail}`); - expect(rect.width).toBeGreaterThan(0); - expect(rect.height).toBeGreaterThan(0); + // 对每台锁(Lock / Lock Pro)各跑 解锁 + 上锁,硬断言首页卡片状态翻转 + for (const lock of LOCKS) { + const ANCHOR = onesCtrl('lock', lock.model); - reporter.record('首页找到门锁卡片', 'PASS', Date.now() - start, detail); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('首页找到门锁卡片', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); + it(`${ANCHOR} ${lock.name} 解锁`, { timeout: 120000 }, async () => { + const start = Date.now(); + try { + const before = await homeReadState(lock.name); + expect(before).not.toBe('unknown'); + const after = await controlLockTo(lock.name, 'Unlocked'); + console.log(`${lock.name} 解锁: ${before} → ${after}`); + expect(after).toBe('Unlocked'); + reporter.record(`${ANCHOR} ${lock.name} 解锁`, 'PASS', Date.now() - start, `首页卡片解锁,状态=${after}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ANCHOR} ${lock.name} 解锁`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); - it('门锁卡片状态显示', async () => { - const start = Date.now(); - try { - const cardId = await findLockCard(); - const rect = await driver.getElementRect(cardId); - - // Check the source around the lock card for status text - const source = await driver.getSource(); - const hasLockedStatus = source.includes('Locked') || source.includes('locked'); - const hasUnlockedStatus = source.includes('Unlocked') || source.includes('unlocked'); - const hasStatus = hasLockedStatus || hasUnlockedStatus; - - const statusText = hasLockedStatus ? 'Locked' : hasUnlockedStatus ? 'Unlocked' : 'Unknown'; - const detail = `门锁卡片状态: ${statusText}, 有状态显示=${hasStatus}`; - console.log(detail); - expect(hasStatus).toBe(true); - - reporter.record('门锁卡片状态显示', 'PASS', Date.now() - start, detail); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('门锁卡片状态显示', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); + it(`${ANCHOR} ${lock.name} 上锁`, { timeout: 120000 }, async () => { + const start = Date.now(); + try { + const before = await homeReadState(lock.name); + expect(before).not.toBe('unknown'); + const after = await controlLockTo(lock.name, 'Locked'); + console.log(`${lock.name} 上锁: ${before} → ${after}`); + expect(after).toBe('Locked'); + reporter.record(`${ANCHOR} ${lock.name} 上锁`, 'PASS', Date.now() - start, `首页卡片上锁,状态=${after}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ANCHOR} ${lock.name} 上锁`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + } }); diff --git a/tests/lock/lock_control.test.ts b/tests/lock/lock_control.test.ts deleted file mode 100644 index bc6f320..0000000 --- a/tests/lock/lock_control.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; -import { DeviceDriver } from '../../drivers/types'; -import { createDriver } from '../../drivers/factory'; -import { TestReporter } from '../../utils/test-reporter'; -import { - sleep, - scrollToAndTap, - waitForSource, -} from '../../utils/common'; -import { getDeviceName } from '../../config/device.config'; -import * as dotenv from 'dotenv'; -import * as path from 'path'; - -dotenv.config({ path: path.resolve(__dirname, '../../.env') }); - -const deviceName = getDeviceName('lock', 'LOCK_DEVICE'); - -// 必测项控制锚点(协议相关): 点击控制Lock 开锁/解锁 BLE 15975#5YEfmpJz / WiFi 15974#7cEkbh4Y -// 上锁+解锁两个用例同覆盖此 step,回写按 step 聚合 -import { onesCtrl } from "../../utils/common"; -const CTRL_LOCK = onesCtrl("lock", deviceName); - -describe('Lock Control - 功能页操作', () => { - let driver: DeviceDriver; - let reporter: TestReporter; - - beforeAll(async () => { - driver = createDriver(); - await driver.createSession(); - reporter = new TestReporter('Lock_Control', driver.platform.toUpperCase()); - }); - - beforeEach(async () => { - await driver.dismissPopupIfPresent(); - await driver.goBackToHomepage(); - await driver.dismissPopupIfPresent(); - }); - - afterAll(async () => { - reporter.generate(); - await driver.destroySession(); - }); - - async function enterControlPage(): Promise { - let el = await driver.findElementRaw('name', deviceName); - if (!el) { - await driver.scrollDown(250); - await sleep(1000); - el = await driver.findElementRaw('name', deviceName); - } - if (!el) throw new Error(`找不到${deviceName}卡片`); - await driver.tapElement(el); - await sleep(3000); - } - - it('功能页-上锁', async () => { - const start = Date.now(); - try { - await enterControlPage(); - - // Find and tap lock button - const lockBtn = await driver.findElementRaw('name', 'Lock') || - await driver.findElementRaw('name', 'lock') || - await driver.findElementRaw('name', 'LOCK'); - expect(lockBtn).not.toBeNull(); - await driver.tapElement(lockBtn!); - await sleep(8000); - - // Verify locked state - const source = await driver.getSource(); - const isLocked = source.includes('Locked') || source.includes('locked') || - source.includes('LOCKED') || source.includes('Lock'); - console.log('上锁状态:', isLocked); - expect(isLocked).toBe(true); - - reporter.record(`${CTRL_LOCK} 上锁`, 'PASS', Date.now() - start, '门锁已上锁'); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record(`${CTRL_LOCK} 上锁`, 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('功能页-解锁', async () => { - const start = Date.now(); - try { - await enterControlPage(); - - // Find and tap unlock button - const unlockBtn = await driver.findElementRaw('name', 'Unlock') || - await driver.findElementRaw('name', 'unlock') || - await driver.findElementRaw('name', 'UNLOCK') || - await driver.findElementRaw('name', 'Open'); - expect(unlockBtn).not.toBeNull(); - await driver.tapElement(unlockBtn!); - await sleep(8000); - - // Verify unlocked state - const source = await driver.getSource(); - const isUnlocked = source.includes('Unlocked') || source.includes('unlocked') || - source.includes('UNLOCKED') || source.includes('Open'); - console.log('解锁状态:', isUnlocked); - expect(isUnlocked).toBe(true); - - // Re-lock for safety - const relockBtn = await driver.findElementRaw('name', 'Lock') || - await driver.findElementRaw('name', 'lock'); - if (relockBtn) { - await driver.tapElement(relockBtn); - await sleep(5000); - } - - reporter.record(`${CTRL_LOCK} 解锁`, 'PASS', Date.now() - start, '门锁已解锁'); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record(`${CTRL_LOCK} 解锁`, 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('绑定Keypad', async () => { - const start = Date.now(); - try { - await enterControlPage(); - - const tapped = await scrollToAndTap(driver, 'Pair Keypad') || - await scrollToAndTap(driver, 'Keypad'); - expect(tapped).toBe(true); - await sleep(3000); - - const source = await driver.getSource(); - const hasBindingPage = source.includes('Keypad') || source.includes('Pair') || - source.includes('Scan') || source.includes('Add'); - expect(hasBindingPage).toBe(true); - - console.log('Keypad绑定页面验证成功'); - reporter.record('绑定Keypad', 'PASS', Date.now() - start, 'Pair Keypad页面打开成功'); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('绑定Keypad', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('绑定Remote', async () => { - const start = Date.now(); - try { - await enterControlPage(); - - const tapped = await scrollToAndTap(driver, 'Pair Remote') || - await scrollToAndTap(driver, 'Remote'); - expect(tapped).toBe(true); - await sleep(3000); - - const source = await driver.getSource(); - const hasBindingPage = source.includes('Remote') || source.includes('Pair') || - source.includes('Scan') || source.includes('Add'); - expect(hasBindingPage).toBe(true); - - console.log('Remote绑定页面验证成功'); - reporter.record('绑定Remote', 'PASS', Date.now() - start, 'Pair Remote页面打开成功'); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('绑定Remote', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); - - it('查看日志', async () => { - const start = Date.now(); - try { - await enterControlPage(); - - const tapped = await scrollToAndTap(driver, 'Logs'); - expect(tapped).toBe(true); - await waitForSource(driver, 'Logs', 5000); - - const source = await driver.getSource(); - expect(source).toContain('Logs'); - const hasEntries = source.includes('Locked') || source.includes('Unlocked') || - source.includes('Lock') || source.includes('Unlock') || - source.includes('App Control') || source.includes('Manual') || - source.includes('No more data') || source.includes('No logs'); - - const detail = `Logs页面加载成功, 有操作记录=${hasEntries}`; - console.log(detail); - reporter.record('查看日志', 'PASS', Date.now() - start, detail); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('查看日志', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); -}); diff --git a/tests/meter/meter_control.test.ts b/tests/meter/meter_control.test.ts index 56b41bc..182e72d 100644 --- a/tests/meter/meter_control.test.ts +++ b/tests/meter/meter_control.test.ts @@ -2,9 +2,12 @@ import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; +import { setupResilientHooks } from "../../utils/common"; import { sleep, scrollToAndTap, + setTemperatureUnit, + setHumidityType, } from '../../utils/common'; import { getDeviceName } from '../../config/device.config'; import * as dotenv from 'dotenv'; @@ -20,6 +23,7 @@ const FEAT_UNIT = '[P0][ONES:74078]'; describe('Meter Control - 功能页操作', () => { let driver: DeviceDriver; let reporter: TestReporter; + setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); @@ -39,65 +43,54 @@ describe('Meter Control - 功能页操作', () => { }); async function enterMeterControl(): Promise { - const el = await driver.findElementRaw('name', deviceName); - if (!el) throw new Error(`找不到${deviceName}卡片`); - await driver.tapElement(el); + // 首页设备多 → 滚动查找卡片(scrollToAndTap 用 UiScrollable.scrollIntoView) + const ok = await scrollToAndTap(driver, deviceName); + if (!ok) throw new Error(`找不到${deviceName}卡片`); await sleep(3000); } - it('切换单位为°C', async () => { + it('切换为摄氏度°C + 相对湿度%', { timeout: 120000 }, async () => { const start = Date.now(); try { + // 全局单位在 Profile → Preferences 切换 + expect(await setTemperatureUnit(driver, 'C')).toBe(true); + expect(await setHumidityType(driver, 'relative')).toBe(true); + // 回 Meter 功能页校验温度显示为 °C(湿度 %/g·m³ 在功能页同时存在,湿度生效已由 setHumidityType 回读 Preferences 校验) + await driver.goBackToHomepage(); + await sleep(1500); await enterMeterControl(); - - const celsiusEl = await driver.findElementRaw('name', '°C') || - await driver.findElementRaw('name', 'C') || - await driver.findElementRaw('name', '℃'); - expect(celsiusEl).not.toBeNull(); - await driver.tapElement(celsiusEl!); - await sleep(2000); - - const source = await driver.getSource(); - const hasCelsius = source.includes('°C') || source.includes('℃'); - expect(hasCelsius).toBe(true); - - reporter.record(`${FEAT_UNIT} 切换单位为°C`, 'PASS', Date.now() - start, '温度单位已切换为°C'); + const src = await driver.getSource(); + expect(src.includes('°C') || src.includes('℃')).toBe(true); + expect(src.includes('°F') || src.includes('℉')).toBe(false); + reporter.record(`${FEAT_UNIT} 切换为°C+相对湿度%`, 'PASS', Date.now() - start, '温度°C(功能页校验)+ 湿度相对%(Preferences校验)'); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); - reporter.record(`${FEAT_UNIT} 切换单位为°C`, 'FAIL', Date.now() - start, e.message, ss); + reporter.record(`${FEAT_UNIT} 切换为°C+相对湿度%`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); - it('切换单位为°F', async () => { + it('切换为华氏度°F + 绝对湿度g/m³', { timeout: 120000 }, async () => { const start = Date.now(); try { + expect(await setTemperatureUnit(driver, 'F')).toBe(true); + expect(await setHumidityType(driver, 'absolute')).toBe(true); + // 回 Meter 功能页校验温度显示为 °F + await driver.goBackToHomepage(); + await sleep(1500); await enterMeterControl(); - - const fahrenheitEl = await driver.findElementRaw('name', '°F') || - await driver.findElementRaw('name', 'F') || - await driver.findElementRaw('name', '℉'); - expect(fahrenheitEl).not.toBeNull(); - await driver.tapElement(fahrenheitEl!); - await sleep(2000); - - const source = await driver.getSource(); - const hasFahrenheit = source.includes('°F') || source.includes('℉'); - expect(hasFahrenheit).toBe(true); - - // Restore to °C - const celsiusEl = await driver.findElementRaw('name', '°C') || - await driver.findElementRaw('name', 'C') || - await driver.findElementRaw('name', '℃'); - if (celsiusEl) { - await driver.tapElement(celsiusEl); - await sleep(1000); - } - - reporter.record(`${FEAT_UNIT} 切换单位为°F`, 'PASS', Date.now() - start, '温度单位已切换为°F并还原'); + const src = await driver.getSource(); + expect(src.includes('°F') || src.includes('℉')).toBe(true); + expect(src.includes('°C') || src.includes('℃')).toBe(false); + reporter.record(`${FEAT_UNIT} 切换为°F+绝对湿度g/m³`, 'PASS', Date.now() - start, '温度°F(功能页校验)+ 湿度绝对g/m³(Preferences校验)'); + // 还原默认:°C + 相对湿度% + await driver.goBackToHomepage(); + await sleep(1000); + await setTemperatureUnit(driver, 'C'); + await setHumidityType(driver, 'relative'); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); - reporter.record(`${FEAT_UNIT} 切换单位为°F`, 'FAIL', Date.now() - start, e.message, ss); + reporter.record(`${FEAT_UNIT} 切换为°F+绝对湿度g/m³`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); @@ -126,30 +119,4 @@ describe('Meter Control - 功能页操作', () => { } }); - it('导出数据', async () => { - const start = Date.now(); - try { - await enterMeterControl(); - - const exportEl = await driver.findElementRaw('name', 'Export') || - await driver.findElementRaw('name', 'export'); - if (!exportEl) { - await scrollToAndTap(driver, 'Export'); - } else { - await driver.tapElement(exportEl); - } - await sleep(3000); - - const source = await driver.getSource(); - const hasExportOptions = source.includes('Export') || source.includes('CSV') || - source.includes('Share') || source.includes('Email') || source.includes('Loading'); - expect(hasExportOptions).toBe(true); - - reporter.record('导出数据', 'PASS', Date.now() - start, `导出选项/对话框已出现`); - } catch (e: any) { - const ss = await driver.screenshot().catch(() => ''); - reporter.record('导出数据', 'FAIL', Date.now() - start, e.message, ss); - throw e; - } - }); }); diff --git a/tests/meter/meter_plus_connect.test.ts b/tests/meter/meter_plus_connect.test.ts new file mode 100644 index 0000000..7b7d6f3 --- /dev/null +++ b/tests/meter/meter_plus_connect.test.ts @@ -0,0 +1,72 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + onesAdd, +} from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Meter Plus 与普通 Meter 共用"Meter"添加入口,App 扫描时发现处于配对模式的那台(Meter Plus 在 ch47)。 +// 首页实际名形如 "Meter Plus 0B"(型号后缀两位十六进制)。可用 env 覆盖 +const deviceName = process.env.METER_PLUS_DEVICE || 'Meter Plus'; +const METER_PLUS_PATTERN = process.env.METER_PLUS_PATTERN || 'Meter Plus\\s+[0-9A-Fa-f]{2}'; +// ONES 锚点:Meter Plus 归在 'meter' 品类(add: 15956) +const ADD_ANCHOR = onesAdd('meter', 'Meter Plus'); + +describe('Meter Plus Connect - 添加Meter Plus设备', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('MeterPlus_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE添加Meter Plus设备`, { timeout: 150000 }, async () => { + const start = Date.now(); + try { + // 已添加则按模式拿到首页实际名并回写;否则走 ch47 继电器配对添加 + const existing = await findDeviceNameOnHomepage(driver, METER_PLUS_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('meterPlus', existing); + reporter.record(`${ADD_ANCHOR} 添加Meter Plus设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: 'Meter', // 与普通 Meter 共用入口(无独立 Meter Plus 品类) + categoryScrollHint: 4, // 与 Meter 同位置,约第 4 次下滑可见(实测) + deviceKeyword: deviceName, + relayDevice: 'meter_plus', // config/relay.config: ch47, 长按 2s + registryCategory: 'meterPlus', + namePattern: METER_PLUS_PATTERN, // 首页实际名(如 "Meter Plus 0B") + connectionKeywords: [ + 'Initial Setup', 'Start Using', 'Done', 'added successfully', + 'Got it', 'cloud service', 'Pick a room', 'Display Type', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Meter Plus设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Meter Plus设备`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/meter/meter_pro_connect.test.ts b/tests/meter/meter_pro_connect.test.ts new file mode 100644 index 0000000..3482dfa --- /dev/null +++ b/tests/meter/meter_pro_connect.test.ts @@ -0,0 +1,73 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + onesAdd, + setupResilientHooks, +} from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Meter Pro (CO2):纯 BLE,与 Meter 添加流程一致,仅「品类入口名」不同(relay ch4)。 +// 品类名/首页名先按 "CO2 Meter",升级后按添加页实际名调(env 可覆盖)。 +const CATEGORY = process.env.METER_PRO_CATEGORY || 'Meter Pro (CO2 Monitor)'; +const deviceName = process.env.METER_PRO_DEVICE || 'Meter Pro'; +const NAME_PATTERN = process.env.METER_PRO_PATTERN || '(Meter Pro|CO2 Monitor)\\s*[0-9A-Fa-f]{0,4}'; +const ADD_ANCHOR = '[P0][ONES:267366]'; + +describe('Meter Pro Connect - 添加Meter Pro(CO2)设备', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('MeterPro_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE添加Meter Pro(CO2)`, { timeout: 150000 }, async () => { + const start = Date.now(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('meter_pro', existing); + reporter.record(`${ADD_ANCHOR} 添加Meter Pro`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: CATEGORY, + categoryScrollHint: 4, // 与 Meter 同区,约 4 次下滑;升级后按实际调 + deviceKeyword: deviceName, + relayDevice: 'meter_pro', // 继电器 ch4 主键 + registryCategory: 'meter_pro', + namePattern: NAME_PATTERN, + connectionKeywords: [ + 'Initial Setup', 'Start Using', 'Done', 'added successfully', + 'Got it', 'cloud service', 'Pick a room', 'Display Type', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Meter Pro`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Meter Pro`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/meter/outdoor_meter_connect.test.ts b/tests/meter/outdoor_meter_connect.test.ts index 80a92e4..f663911 100644 --- a/tests/meter/outdoor_meter_connect.test.ts +++ b/tests/meter/outdoor_meter_connect.test.ts @@ -9,7 +9,6 @@ import { saveAddedDevice, onesAdd, } from '../../utils/common'; -import { getModelName } from '../../config/device.config'; import * as dotenv from 'dotenv'; import * as path from 'path'; @@ -18,7 +17,7 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') }); // outdoor meter 是套件:选 "Outdoor Meter" → 过免责 → 选择页先点 "Add Meter" → 按 ch36 → 连接。 // 首页实际名 "Outdoor Meter XX"(与 indoor "Meter XX" 前缀不同,要求十六进制后缀避免误匹配)。 const OUTDOOR_PATTERN = process.env.OUTDOOR_METER_PATTERN || 'Outdoor Meter\\s+[0-9A-Fa-f]{2}'; -const ADD_ANCHOR = onesAdd('meter', getModelName('outdoor_meter')); // 暂无独立锚点映射,留接口 +const ADD_ANCHOR = onesAdd('sensor', 'IOSensor'); // IOSensor=Outdoor Meter → ONES 35871(添加IOSensor验证) describe('Outdoor Meter Connect - 添加户外温湿度计(套件)', () => { let driver: DeviceDriver; diff --git a/tests/platform/create_room.test.ts b/tests/platform/create_room.test.ts new file mode 100644 index 0000000..476f9da --- /dev/null +++ b/tests/platform/create_room.test.ts @@ -0,0 +1,50 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { goToManageRooms, createRoom, deleteRoom, signInWithEmail, isLoggedIn, onesPlatform } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const ANCHOR = `${onesPlatform(15944)} 创建房间`; // P0:创建房间验证 + +describe('Platform - 创建房间', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + const roomName = `AutoRoom${Date.now() % 100000}`; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Platform_CreateRoom', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(ANCHOR, { timeout: 180000 }, async () => { + const start = Date.now(); + try { + if (!(await isLoggedIn(driver))) await signInWithEmail(driver); + // 进房间管理 → 创建房间 → 校验 + const nav = await goToManageRooms(driver); + expect(nav).toBe(true); + const created = await createRoom(driver, roomName); + expect(created).toBe(true); + // 删除房间恢复数据(创建的测试房间用完即删) + const deleted = await deleteRoom(driver, roomName); + expect(deleted).toBe(true); + reporter.record(ANCHOR, 'PASS', Date.now() - start, `创建并删除房间 ${roomName} 成功(数据已恢复)`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(ANCHOR, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/platform/feedback.test.ts b/tests/platform/feedback.test.ts new file mode 100644 index 0000000..b32cf4e --- /dev/null +++ b/tests/platform/feedback.test.ts @@ -0,0 +1,50 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { navigateToFeedback, submitFeedback, captureFeedbackTicketId, signInWithEmail, isLoggedIn, onesPlatform } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const ANCHOR = `${onesPlatform(15945)} feedback提交`; // P0:feedback提交验证 + +describe('Platform - feedback 提交', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Platform_Feedback', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(ANCHOR, { timeout: 180000 }, async () => { + const start = Date.now(); + try { + // 需登录态(主账号) + if (!(await isLoggedIn(driver))) await signInWithEmail(driver); + const nav = await navigateToFeedback(driver); + expect(nav).toBe(true); + // 提交工单:Question Type=App issues(idx 3),Question Details=内测问题 + const ok = await submitFeedback(driver, { details: '内测问题直接关闭热更插件', typeIndex: 3 }); + expect(ok).toBe(true); + // 抓 ticket id(供插件热更 48734 用例消费) + const ticketId = await captureFeedbackTicketId(driver); + expect(ticketId).toBeTruthy(); + reporter.record(ANCHOR, 'PASS', Date.now() - start, `feedback 提交成功, ticket=${ticketId}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(ANCHOR, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/platform/message_center.test.ts b/tests/platform/message_center.test.ts new file mode 100644 index 0000000..332a215 --- /dev/null +++ b/tests/platform/message_center.test.ts @@ -0,0 +1,46 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { openMessageCenter, tapFirstMessage, signInWithEmail, isLoggedIn, onesPlatform } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const ANCHOR = `${onesPlatform(298968)} 消息中心`; // P0:消息中心 + +describe('Platform - 消息中心', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Platform_MessageCenter', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(ANCHOR, { timeout: 120000 }, async () => { + const start = Date.now(); + try { + if (!(await isLoggedIn(driver))) await signInWithEmail(driver); + const ok = await openMessageCenter(driver); + expect(ok).toBe(true); + // 点第一条消息 → 跳转内容/详情页 + const jumped = await tapFirstMessage(driver); + expect(jumped).toBe(true); + reporter.record(ANCHOR, 'PASS', Date.now() - start, '消息中心打开 + 消息内容跳转'); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(ANCHOR, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/automation/rn_plugin.test.ts b/tests/platform/rn_plugin.test.ts similarity index 100% rename from tests/automation/rn_plugin.test.ts rename to tests/platform/rn_plugin.test.ts diff --git a/tests/platform/scene_operation.test.ts b/tests/platform/scene_operation.test.ts new file mode 100644 index 0000000..9a0ba78 --- /dev/null +++ b/tests/platform/scene_operation.test.ts @@ -0,0 +1,104 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { execSync } from 'child_process'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { setupResilientHooks } from "../../utils/common"; +import { + sleep, ensureHomeTab, onesPlatform, + createAutomation, verifyAutomationExecuted, deleteAutomation, + createScene, executeSceneFromHomepage, verifySceneInLogs, deleteScene, + signInWithEmail, isLoggedIn, +} from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const ANCHOR = `${onesPlatform(15976)} 场景操作`; // P0:场景操作验证(① 自动化 ② 场景) + +/** 读安卓设备当前时间 [h, m]。 */ +function deviceNow(): [number, number] { + const out = execSync('adb shell date +%H:%M').toString().trim().split(':'); + return [parseInt(out[0], 10), parseInt(out[1], 10)]; +} +/** 在 [h,m] 上加 addMin 分钟,返回 [h,m](跨小时进位)。 */ +function plusMin(h: number, m: number, addMin: number): [number, number] { + const t = (h * 60 + m + addMin) % (24 * 60); + return [Math.floor(t / 60), t % 60]; +} + +describe('Platform - 场景操作(自动化 + 场景)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + const autoName = `AutoOp${Date.now() % 100000}`; + const sceneName = `SceneOp${Date.now() % 100000}`; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('Platform_SceneOp', driver.platform.toUpperCase()); + if (!(await isLoggedIn(driver))) await signInWithEmail(driver); + }); + + afterAll(async () => { + // 兜底清理 + try { await deleteAutomation(driver, autoName); } catch { /**/ } + try { await ensureHomeTab(driver); await deleteScene(driver, sceneName); } catch { /**/ } + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ANCHOR} ① 自动化创建与执行`, { timeout: 360000 }, async () => { + const start = Date.now(); + try { + // 定时设为设备时间 + 2 分钟(创建较快,留出创建耗时后约 1 分钟内触发) + const [nh, nm] = deviceNow(); + const [th, tm] = plusMin(nh, nm, 2); + console.log(`设备时间 ${nh}:${nm} → 定时 ${th}:${tm}`); + const created = await createAutomation(driver, { name: autoName, deviceKeyword: 'Bot 14', targetHour: th, targetMinute: tm, action: 'Turns off' }); + expect(created).toBe(true); + + // 等到点(目标时间 + 40s 让引擎触发+落日志) + const deadlineMin = th * 60 + tm; + for (let i = 0; i < 60; i++) { + const [ch, cm] = deviceNow(); + if (ch * 60 + cm > deadlineMin) break; + await sleep(10000); + } + await sleep(40000); + + const executed = await verifyAutomationExecuted(driver, autoName); + expect(executed).toBe(true); + await deleteAutomation(driver, autoName); + reporter.record(`${ANCHOR} ①自动化`, 'PASS', Date.now() - start, `自动化 ${autoName} 定时${th}:${tm} 触发并有日志,已删除`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ANCHOR} ①自动化`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${ANCHOR} ② 场景创建与执行`, { timeout: 180000 }, async () => { + const start = Date.now(); + try { + // 场景动作与自动化(Bot Turns off)区分:Bot Turns on(同设备不同动作) + const created = await createScene(driver, { name: sceneName, deviceKeyword: 'Bot 14', action: 'Turns on' }); + expect(created).toBe(true); + await ensureHomeTab(driver); + const executed = await executeSceneFromHomepage(driver, sceneName); + expect(executed).toBe(true); + await sleep(6000); + await driver.goBackToHomepage(); + const hasLog = await verifySceneInLogs(driver, sceneName); + expect(hasLog).toBe(true); + await deleteScene(driver, sceneName); + reporter.record(`${ANCHOR} ②场景`, 'PASS', Date.now() - start, `场景 ${sceneName} 执行并有日志,已删除`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ANCHOR} ②场景`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/plug/plug_mini_connect.test.ts b/tests/plug/plug_mini_connect.test.ts new file mode 100644 index 0000000..cb8aaeb --- /dev/null +++ b/tests/plug/plug_mini_connect.test.ts @@ -0,0 +1,88 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + configureHubWifi, + onesAdd, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Plug Mini 是 WiFi 产品:relay 按键配对 + WiFi 配网。ch1=JP(默认)/ch2=US。 +const PLUG_MINI_CATEGORY = process.env.PLUG_MINI_CATEGORY || 'Plug Mini (JP)'; +const RELAY_DEVICE = process.env.PLUG_MINI_RELAY || 'plug_mini'; // JP=plug_mini(ch1) / US=plug_mini_us(ch2) +const deviceName = process.env.PLUG_MINI_DEVICE || PLUG_MINI_CATEGORY; // 首页名形如 "Plug Mini (EU) 2A" = 品类名+后缀,**含区域** +// 区域专属校验:从品类名派生(转义括号),避免 JP/US/EU 互相串台误判(之前用通用 "Plug Mini XX" 会匹配到任意一个 → 假通过) +const PLUG_MINI_PATTERN = process.env.PLUG_MINI_PATTERN || `${PLUG_MINI_CATEGORY.replace(/[()]/g, '\\$&')}\\s*[0-9A-Za-z]{1,2}`; +const ADD_ANCHOR = process.env.PLUG_MINI_ONES ? `[P0][ONES:${process.env.PLUG_MINI_ONES}]` : onesAdd('plug', 'PlugMini 5E'); // EU 传 PLUG_MINI_ONES=332918 + +describe('Plug Mini Connect - 添加Plug Mini设备(串口配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('PlugMini_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加Plug Mini(${PLUG_MINI_CATEGORY})`, { timeout: 360000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, PLUG_MINI_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('plug', existing); + reporter.record(`${ADD_ANCHOR} 添加${PLUG_MINI_CATEGORY}`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: PLUG_MINI_CATEGORY, + categoryScrollHint: 0, + deviceKeyword: deviceName, + relayDevice: RELAY_DEVICE, // ch1(JP)/ch2(US),长按 2s + registryCategory: 'plug', + namePattern: PLUG_MINI_PATTERN, + // plug 第一次按键+Next 即连上去 WiFi 页;重按会把它弄出连接 → 不重按,只延长等待 + repressOnRetry: false, + tapOtherPairing: process.env.PLUG_OTHER_PAIRING === '1', // JP 自动配对不行,需点"其他配对方式"(PLUG_OTHER_PAIRING=1 启用) + pairRetries: 2, + pairPollIters: 16, + // 选品类后是 "Before Using" 免责页(非 Disclaimer 关键字),beforePairing 里点 Next 过掉 → 到"长按按钮"引导页 + beforePairing: async (d) => { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (n) { await d.tapElement(n); await sleep(2000); } + }, + // 连接后:进 Wi-Fi Settings 页填 SSID/密码(configureHubWifi 已兼容标题/输入框检测) + postConnectSteps: async (d) => { await configureHubWifi(d, wifi); }, + connectionKeywords: [ + 'Configure Wi-Fi', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加${PLUG_MINI_CATEGORY}`, 'PASS', Date.now() - start, `${deviceName}添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加${PLUG_MINI_CATEGORY}`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/plug/plug_mini_control.test.ts b/tests/plug/plug_mini_control.test.ts new file mode 100644 index 0000000..cf5e0cd --- /dev/null +++ b/tests/plug/plug_mini_control.test.ts @@ -0,0 +1,123 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { sleep, onesCtrl } from '../../utils/common'; +import { setupResilientHooks } from "../../utils/common"; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Plug Mini 控制(ONES step12 2jSFfY4k):首页卡片开关钮直接开/关,卡名下方 desText 显示 On/Off。 +// 用品类前缀 "Plug Mini" 匹配:删后重加卡名后缀/区域会变,textContains 前缀仍命中首个 Plug Mini 卡,状态按坐标读不依赖全名。 +const deviceName = process.env.PLUG_CTRL_DEVICE || 'Plug Mini'; +const CTRL_PLUG = onesCtrl('plug', 'PlugMini 5E'); // 15975#2jSFfY4k + +describe('Plug Mini Control - 首页卡片开关', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('PlugMini_Control', driver.platform.toUpperCase()); + }); + + beforeEach(async () => { + await driver.dismissPopupIfPresent(); + await driver.goBackToHomepage(); + await sleep(500); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + // 找 plug 卡片:状态 = 卡名正下方同列的 "On"/"Off";开关钮在卡名右上方。 + // RN 首页 UiScrollable.scrollIntoView 对部分卡片失效 → 手动滚动 + 从 getSource 解析卡名 bounds(滚到时卡片在视口下方,开关钮 y-95 自然不落顶栏)。 + function findCardRect(src: string): { x: number; y: number; w: number } | null { + for (const n of src.split('<')) { + if (!/resource-id="[^"]*\/nameText"/.test(n)) continue; + const t = (n.match(/text="([^"]*)"/) || [])[1]; + if (!t || !t.includes(deviceName)) continue; + const b = n.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); + if (b) return { x: +b[1], y: +b[2], w: +b[3] - +b[1] }; + } + return null; + } + async function plugInfo(): Promise<{ state: 'On' | 'Off' | 'unknown'; btnX: number; btnY: number; nameX: number; nameY: number } | null> { + await driver.goBackToHomepage(); await sleep(800); await driver.dismissPopupIfPresent(); + let rect: { x: number; y: number; w: number } | null = null; + for (let i = 0; i < 8 && !rect; i++) { + rect = findCardRect(await driver.getSource()); + if (!rect) { await driver.scrollDown(500); await sleep(700); } + } + if (!rect) return null; + // 开关钮 y-95 若落进顶栏(~y248)会误触 +/... → 卡片太靠顶时下滑一屏让它下移再读 + if (rect.y - 95 < 300) { + await driver.scrollDown(400); await sleep(800); + const r2 = findCardRect(await driver.getSource()); + if (r2) rect = r2; + } + const state = readPlugState(await driver.getSource(), rect.x, rect.y); + return { state, btnX: rect.x + rect.w - 48, btnY: rect.y - 95, nameX: rect.x, nameY: rect.y }; + } + + function readPlugState(src: string, nameX: number, nameY: number): 'On' | 'Off' | 'unknown' { + for (const n of src.split('<')) { + const t = (n.match(/text="(On|Off)"/) || [])[1] as 'On' | 'Off' | undefined; + const b = n.match(/bounds="\[(\d+),(\d+)\]/); + if (t && b && Math.abs(+b[1] - nameX) < 200 && +b[2] > nameY && +b[2] < nameY + 150) return t; + } + return 'unknown'; + } + + async function controlTo(target: 'On' | 'Off'): Promise<'On' | 'Off' | 'unknown'> { + const info = await plugInfo(); + if (!info) throw new Error(`首页找不到${deviceName}卡片`); + if (info.state === 'unknown') throw new Error(`读不到${deviceName}开关状态`); + if (info.state === target) return target; + await driver.tap(info.btnX, info.btnY); // 点开关钮 + let st = info.state; + for (let i = 0; i < 8 && st !== target; i++) { + await sleep(2000); + st = (await plugInfo())?.state ?? 'unknown'; + } + return st; + } + + it(`${CTRL_PLUG} 关闭开关`, { timeout: 90000 }, async () => { + const start = Date.now(); + try { + const before = (await plugInfo())?.state; + expect(before).not.toBe('unknown'); + const after = await controlTo('Off'); + console.log(`关闭: ${before} → ${after}`); + expect(after).toBe('Off'); // 硬断言:已关 + reporter.record(`${CTRL_PLUG} 关闭开关`, 'PASS', Date.now() - start, `状态=${after}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${CTRL_PLUG} 关闭开关`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); + + it(`${CTRL_PLUG} 打开开关`, { timeout: 90000 }, async () => { + const start = Date.now(); + try { + const before = (await plugInfo())?.state; + expect(before).not.toBe('unknown'); + const after = await controlTo('On'); + console.log(`打开: ${before} → ${after}`); + expect(after).toBe('On'); // 硬断言:已开 + reporter.record(`${CTRL_PLUG} 打开开关`, 'PASS', Date.now() - start, `状态=${after}`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${CTRL_PLUG} 打开开关`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/safety_alarm/safety_alarm_connect.test.ts b/tests/safety_alarm/safety_alarm_connect.test.ts new file mode 100644 index 0000000..8c6fcb3 --- /dev/null +++ b/tests/safety_alarm/safety_alarm_connect.test.ts @@ -0,0 +1,85 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + enterPairingMode, + sleep, + onesAdd, + setupResilientHooks, +} from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Safety Alarm:**无 WiFi 模块**,纯 BLE,与 Find Card 同品类组、流程相近(选品类→配对键+Connect Device→直连命名)。relay ch3。 +const CATEGORY = process.env.SAFETY_ALARM_CATEGORY || 'Safety Alarm'; +const deviceName = process.env.SAFETY_ALARM_DEVICE || 'Safety Alarm'; +const NAME_PATTERN = process.env.SAFETY_ALARM_PATTERN || 'Safety Alarm\\s*[0-9A-Za-z]{0,4}'; +const ADD_ANCHOR = '[P0][ONES:332919]'; + +describe('Safety Alarm Connect - 添加Safety Alarm设备(BLE,无WiFi)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('SafetyAlarm_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE添加Safety Alarm`, { timeout: 180000 }, async () => { + const start = Date.now(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('safety_alarm', existing); + reporter.record(`${ADD_ANCHOR} 添加Safety Alarm`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: deviceName, + skipScanStep: true, // 同 find card:无扫描列表,配对键 + Connect Device 后直连命名页 + // 选品类后:① Disclaimers 免责页先 Next 过掉 ② relay ch3 长按 2s 进配对 ③ 点 Connect Device 直连 + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); // 过 Disclaimers(SOS 紧急警报免责) + } + await enterPairingMode('safety_alarm').catch((e) => console.log(`relay ch3 按键失败: ${e.message}`)); + await sleep(800); + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Devices")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + else console.log('未找到 Connect Device 按钮'); + }, + wizardButtons: ['Next', 'Save', 'Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip'], + connectionKeywords: [ + 'Pick a room', 'Name', 'Save', 'added successfully', 'Added successfully', 'Got it', 'Done', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Safety Alarm`, 'PASS', Date.now() - start, `${deviceName}添加成功(BLE), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Safety Alarm`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/sensor/contact_sensor_connect.test.ts b/tests/sensor/contact_sensor_connect.test.ts new file mode 100644 index 0000000..b5adc14 --- /dev/null +++ b/tests/sensor/contact_sensor_connect.test.ts @@ -0,0 +1,75 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + onesAdd, +} from '../../utils/common'; +import { getDeviceName, getModelName } from '../../config/device.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const deviceName = getDeviceName('sensor', 'CONTACT_DEVICE'); // 默认 'Contact Sensor 5O' +// 首页实际名形如 "Contact Sensor 5O"(型号后缀两位字母/数字)。可用 env 覆盖 +const CONTACT_PATTERN = process.env.CONTACT_PATTERN || 'Contact Sensor\\s+[0-9A-Za-z]{2}'; +// ONES 锚点:Contact Sensor 归在 'sensor' 品类(add: 15962) +const ADD_ANCHOR = onesAdd('sensor', getModelName('sensor', 'CONTACT_DEVICE')); + +describe('Contact Sensor Connect - 添加Contact Sensor设备', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('ContactSensor_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE添加Contact Sensor设备`, { timeout: 180000 }, async () => { + const start = Date.now(); + try { + // 已添加则按模式拿到首页实际名并回写;否则走 ch44 继电器配对添加 + const existing = await findDeviceNameOnHomepage(driver, CONTACT_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('sensor', existing); + reporter.record(`${ADD_ANCHOR} 添加Contact Sensor设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: 'Contact Sensor', + categoryScrollHint: 4, // 实测滑4次到,边滑边找(首跑打印实际次数后再提速) + deviceKeyword: deviceName, + relayDevice: 'contact', // config/relay.config: ch44, 长按 2s(待实测确认) + registryCategory: 'sensor', + namePattern: CONTACT_PATTERN, // 首页实际名(如 "Contact Sensor 5O") + // contact 向导:命名页=Save;安装指引=Next;安装门页底部按钮文字="Test the installation"(实为继续键 tvInstallDoor3Next); + // 测试页有 "Done" 完成(无需真实开合门即可结束);可能有 OK 确认弹窗。完成=Done。 + wizardButtons: ['Save', 'Test the installation', 'Done', 'Next', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip'], + connectionKeywords: [ + 'Pick a room', 'Save', 'Initial Setup', 'added successfully', 'Added successfully', + 'Got it', 'Done', 'How to install', 'Test the installation', 'Test the Installation', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Contact Sensor设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Contact Sensor设备`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/sensor/motion_sensor_connect.test.ts b/tests/sensor/motion_sensor_connect.test.ts new file mode 100644 index 0000000..e308dd5 --- /dev/null +++ b/tests/sensor/motion_sensor_connect.test.ts @@ -0,0 +1,74 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + onesAdd, +} from '../../utils/common'; +import { getDeviceName, getModelName } from '../../config/device.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const deviceName = getDeviceName('motion', 'MOTION_DEVICE'); +// 首页实际名形如 "Motion Sensor A5"(型号后缀两位字母/数字)。可用 env 覆盖 +const MOTION_PATTERN = process.env.MOTION_PATTERN || 'Motion Sensor\\s+[0-9A-Za-z]{2}'; +// ONES 锚点按型号名解析;Motion Sensor 归在锚点表的 'sensor' 品类(add: 15963) +const ADD_ANCHOR = onesAdd('sensor', getModelName('motion', 'MOTION_DEVICE')); + +describe('Motion Sensor Connect - 添加Motion Sensor设备', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('MotionSensor_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE添加Motion Sensor设备`, { timeout: 180000 }, async () => { + const start = Date.now(); + try { + // 已添加则按模式拿到首页实际名并回写;否则走 ch40 继电器配对添加 + const existing = await findDeviceNameOnHomepage(driver, MOTION_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('motion', existing); + reporter.record(`${ADD_ANCHOR} 添加Motion Sensor设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: 'Motion Sensor', + categoryScrollHint: 5, // 添加目录里约第 5 次下滑可见(提速) + deviceKeyword: deviceName, + relayDevice: 'motion', // config/relay.config: ch29, 长按 2s + registryCategory: 'motion', + namePattern: MOTION_PATTERN, // 首页实际名(如 "Motion Sensor A5") + // motion 专属向导:命名页=Save;"有宠物吗?"=No;装底座页/引导页=Next;完成=Done + wizardButtons: ['Save', 'No', 'Next', 'Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip'], + connectionKeywords: [ + 'Pick a room', 'Save', 'Initial Setup', 'added successfully', 'Added successfully', + 'Got it', 'Done', 'How to install', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Motion Sensor设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Motion Sensor设备`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/sensor/presence_sensor_connect.test.ts b/tests/sensor/presence_sensor_connect.test.ts new file mode 100644 index 0000000..7aa40e8 --- /dev/null +++ b/tests/sensor/presence_sensor_connect.test.ts @@ -0,0 +1,75 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + onesAdd, + setupResilientHooks, +} from '../../utils/common'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Presence Sensor:与 Motion Sensor(人体传感器)同品类组、同 BLE 添加流程,仅「品类入口名」不同(relay ch5)。 +// 入口名先按 "Presence Sensor",跑时按添加页实际名调(env 可覆盖)。 +const CATEGORY = process.env.PRESENCE_CATEGORY || 'Presence Sensor'; +const deviceName = process.env.PRESENCE_DEVICE || 'Presence Sensor'; +const NAME_PATTERN = process.env.PRESENCE_PATTERN || '(Presence Sensor|Human Presence)\\s*[0-9A-Za-z]{0,4}'; +const ADD_ANCHOR = '[P0][ONES:332917]'; + +describe('Presence Sensor Connect - 添加Presence Sensor设备', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('PresenceSensor_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE添加Presence Sensor设备`, { timeout: 180000 }, async () => { + const start = Date.now(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('presence', existing); + reporter.record(`${ADD_ANCHOR} 添加Presence Sensor设备`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: CATEGORY, + categoryScrollHint: 0, // Presence Sensor 在品类列表较前;0=从顶逐屏找,避免 hint 快滑过冲找不到 + deviceKeyword: deviceName, + relayDevice: 'presence_sensor', // config/relay.config: ch5, 长按 2s + registryCategory: 'presence', + namePattern: NAME_PATTERN, + // 传感器类向导:命名页=Save;可能的"有宠物吗?"=No;装底座/引导页=Next;完成=Done + wizardButtons: ['Save', 'No', 'Next', 'Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip'], + connectionKeywords: [ + 'Pick a room', 'Save', 'Initial Setup', 'added successfully', 'Added successfully', + 'Got it', 'Done', 'How to install', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加Presence Sensor设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加Presence Sensor设备`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/strip_light/neon_light_connect.test.ts b/tests/strip_light/neon_light_connect.test.ts new file mode 100644 index 0000000..3abd52f --- /dev/null +++ b/tests/strip_light/neon_light_connect.test.ts @@ -0,0 +1,93 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + pressButton, + enterPairingMode, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// 炫彩霓虹灯(RGBIC Neon Rope):灯类 WiFi 产品(relay ch41)。流程同灯带:长按配对键 2s + WiFi 配网 + Start now。入口品类不同。 +const CATEGORY = process.env.NEON_CATEGORY || 'Neon Rope Light'; +const SCAN_KEYWORD = process.env.NEON_SCAN || 'Strip Light'; // 霓虹灯与灯带同系列,BLE 扫描名为 Strip Light +const NAME_PATTERN = process.env.NEON_PATTERN || 'Neon[^"]*\\s*[0-9A-Za-z]{0,4}'; + +describe('Neon Light Connect - 添加炫彩霓虹灯(长按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('NeonLight_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it('[P0][ONES:393235] 通过BLE+WiFi添加炫彩霓虹灯', { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('stripLight', existing); + reporter.record('[P0][ONES:393235] 添加炫彩霓虹灯', 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: true, // Connect Device 已直连进配网页,无扫描列表 → 跳过扫描(否则误报 not found) + // 选品类后:① 过 Disclaimers(若有)② 长按配对键 2s(relay ch41)→ 点 Next/Connect Device + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); + } + await enterPairingMode('neon_light').catch((e) => console.log(`relay ch41 长按配对失败: ${e.message}`)); + await sleep(2000); + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + ], + }); + expect(result).toBe(true); + + // 添加完成后灯通常为打开状态 → 按一次按钮关灯 + await pressButton('neon_light', 'main', 400).catch((e) => console.log(`关灯按键失败: ${e.message}`)); + await sleep(1000); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record('[P0][ONES:393235] 添加炫彩霓虹灯', 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record('[P0][ONES:393235] 添加炫彩霓虹灯', 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/strip_light/strip_light_connect.test.ts b/tests/strip_light/strip_light_connect.test.ts index 425a7c5..f74fa62 100644 --- a/tests/strip_light/strip_light_connect.test.ts +++ b/tests/strip_light/strip_light_connect.test.ts @@ -1,38 +1,32 @@ -import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { TestReporter } from '../../utils/test-reporter'; -import { getDeviceName } from '../../config/device.config'; import { sleep, - enterDeviceSettings, - renameDevice, - changeDeviceRoom, - navigateToTimerPage, - addTimer, - deleteAllTimers, - navigateToFirmwarePage, - checkFirmwareVersion, - navigateToDeviceInfo, - getDeviceInfo, - scrollToAndTap, - waitForSource, - enterEditInfo, addDeviceViaBLE, - isDeviceOnHomepage, + findDeviceNameOnHomepage, + saveAddedDevice, + pressButton, + enterPairingMode, + configureHubWifi, + setupResilientHooks, } from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); -const deviceName = getDeviceName('stripLight', 'STRIP_LIGHT_DEVICE'); -import { onesAdd } from "../../utils/common"; -const ADD_ANCHOR = onesAdd("strip_light", deviceName); +// Strip Light(灯带):灯类 WiFi 产品(relay ch33)。流程同 Color Bulb/吸顶灯:**点一次→等2s→再点一次** 进配对 + WiFi 配网 + Start now 完成。 +const CATEGORY = process.env.STRIP_LIGHT_CATEGORY || 'Strip Light'; +const SCAN_KEYWORD = process.env.STRIP_LIGHT_SCAN || 'Strip Light'; +const NAME_PATTERN = process.env.STRIP_LIGHT_PATTERN || 'Strip Light\\s*[0-9A-Za-z]{0,4}'; -describe('StripLight Connect - 添加灯带设备', () => { +describe('StripLight Connect - 添加灯带(两次点按配对 + WiFi配网)', () => { let driver: DeviceDriver; let reporter: TestReporter; + setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); @@ -45,32 +39,53 @@ describe('StripLight Connect - 添加灯带设备', () => { await driver.destroySession(); }); - it(`${ADD_ANCHOR} 通过BLE添加灯带`, async () => { + it('[P0][ONES:15959] 通过BLE+WiFi添加灯带', { timeout: 240000 }, async () => { const start = Date.now(); + const wifi = getWifiCredentials(); try { - const alreadyExists = await isDeviceOnHomepage(driver, deviceName); - if (alreadyExists) { - console.log(`${deviceName}已在首页,跳过重新添加`); - reporter.record(`${ADD_ANCHOR} 添加灯带设备`, 'SKIP', Date.now() - start, `${deviceName}已存在, 无需重新添加`); + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('stripLight', existing); + reporter.record('[P0][ONES:15959] 添加灯带', 'SKIP', Date.now() - start, `${existing}已存在`); return; } const result = await addDeviceViaBLE(driver, { - categoryName: 'Strip Light', - deviceKeyword: deviceName, + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, scanTimeout: 30000, + // 选品类后:① 过 Disclaimers(若有)② 进配对:长按配对键 2s(relay ch33;灯带是长按,非两次点按) + preSelectSteps: async (d) => { + for (let i = 0; i < 2; i++) { + const n = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!n) break; + await d.tapElement(n); await sleep(2000); + } + await enterPairingMode('strip_light').catch((e) => console.log(`relay ch33 长按配对失败: ${e.message}`)); + await sleep(2000); + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")'); + if (btn) { await d.tapElement(btn); await sleep(3000); } + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + // 命名页完成按钮 "Start now"(默认向导没有,灯类必须加,否则设备不落账号) + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], connectionKeywords: [ - 'Initial Setup', 'Start Using', 'Done', 'added successfully', - 'Got it', 'Pick a room', + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', ], }); expect(result).toBe(true); + // 添加完成后灯带通常为打开状态 → 按一次按钮关灯 + await pressButton('strip_light', 'main', 400).catch((e) => console.log(`关灯按键失败: ${e.message}`)); + await sleep(1000); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); - reporter.record(`${ADD_ANCHOR} 添加灯带设备`, 'PASS', Date.now() - start, `${deviceName}添加成功, 耗时${elapsed}s`); + reporter.record('[P0][ONES:15959] 添加灯带', 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); - reporter.record(`${ADD_ANCHOR} 添加灯带设备`, 'FAIL', Date.now() - start, e.message, ss); + reporter.record('[P0][ONES:15959] 添加灯带', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); diff --git a/tests/urc/urc_connect.test.ts b/tests/urc/urc_connect.test.ts new file mode 100644 index 0000000..8d3cecd --- /dev/null +++ b/tests/urc/urc_connect.test.ts @@ -0,0 +1,112 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceViaBLE, + findDeviceNameOnHomepage, + saveAddedDevice, + enterPairingMode, + configureHubWifi, + setupResilientHooks, +} from '../../utils/common'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// URC(Universal Remote 万能遥控器):带 WiFi 模块。入口品类「Universal Remote」。 +// 配对:同时长按 off(ch21)+on(ch22)键(relay 'urc' 的 pairing)。 +// 流程先按 WiFi 直连(Connect Device)起手:选品类 → 过 Next/Disclaimers → 两键同按配对 → Connect Device → WiFi 配网 → 收尾。 +// 首版按截图实测后再校准(扫描列表? 配网页?)。ONES 添加号 190801。 +const CATEGORY = process.env.URC_CATEGORY || 'Universal Remote'; +const SCAN_KEYWORD = process.env.URC_SCAN || 'Universal Remote'; +const NAME_PATTERN = process.env.URC_PATTERN || 'Universal Remote[^"]*\\s*[0-9A-Za-z]{0,4}'; +const SKIP_SCAN = process.env.URC_SKIP_SCAN !== '0'; // 默认直连;=0 回退扫描列表 +const ADD_ANCHOR = '[P0][ONES:190801]'; // 添加URC验证 + +describe('URC Connect - 添加万能遥控器(两键同按配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + setupResilientHooks(() => driver); + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('URC_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加万能遥控器`, { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, NAME_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('urc', existing); + reporter.record(`${ADD_ANCHOR} 添加万能遥控器`, 'SKIP', Date.now() - start, `${existing}已存在`); + return; + } + + const result = await addDeviceViaBLE(driver, { + categoryName: CATEGORY, + deviceKeyword: SCAN_KEYWORD, + scanTimeout: 30000, + skipScanStep: SKIP_SCAN, + // URC 流程:选品类 → 推进到「发现页」(含 Discover device/Connect device)→ **先按继电器(ch21+22)进配对** + // → **再点发现页的 Connect device 按钮**触发发现 → title 含 "add" 即进入添加模式页 → WiFi 配网。 + preSelectSteps: async (d) => { + // 1. 推进到发现页(点 Next/Connect 直到出现 Discover device/Connect device) + for (let i = 0; i < 4; i++) { + const src = await d.getSource(); + if (/Discover device|Connect device/i.test(src)) break; + let moved = false; + for (const t of ['Next', 'Connect']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { await d.tapElement(b); moved = true; break; } + } + if (!moved) break; + await sleep(2000); + } + // 2. 先按继电器进配对模式(就在发现页上按,避免过早退出配对) + await enterPairingMode('urc').catch((e) => console.log(`relay ch21+22 两键同按配对失败: ${e.message}`)); + await sleep(2500); + // 3. 再点发现页的 Connect device 按钮触发发现 + let clicked = ''; + for (const t of ['Connect device', 'Connect Device', 'Connect Devices', 'Connect']) { + const b = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null); + if (b) { await d.tapElement(b); clicked = t; break; } + } + await sleep(3000); + // 诊断:确认是否进添加模式页(title 含 add) + const src = await d.getSource(); + const inAdd = /add/i.test(src); + const btns = [...new Set(Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t && t.length < 24))].slice(0, 25); + console.log(`[URC诊断] 发现页点击="${clicked}" 已进添加页(含add)=${inAdd}`); + console.log(`[URC诊断] 页面文案: ${JSON.stringify(btns)}`); + }, + postConnectionSteps: async (d) => { await configureHubWifi(d, wifi); }, + wizardButtons: ['Start now', 'Return Home', 'Done', 'Use now', 'Start Using', 'Got it', 'OK', 'Skip', 'Next', 'Save'], + connectionKeywords: [ + 'Configure Wi-Fi', 'Wi-Fi Settings', 'Connecting', 'Pick a room', 'Done', + 'added successfully', 'Added successfully', 'Initial Setup', 'Start now', 'Select a room', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加万能遥控器`, 'PASS', Date.now() - start, `添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加万能遥控器`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/tests/water_detector/water_detector_connect.test.ts b/tests/water_detector/water_detector_connect.test.ts new file mode 100644 index 0000000..94e56df --- /dev/null +++ b/tests/water_detector/water_detector_connect.test.ts @@ -0,0 +1,82 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; +import { createDriver } from '../../drivers/factory'; +import { TestReporter } from '../../utils/test-reporter'; +import { + sleep, + addDeviceWithSerialPairing, + findDeviceNameOnHomepage, + saveAddedDevice, + configureHubWifi, + onesAdd, +} from '../../utils/common'; +import { getDeviceName, getModelName } from '../../config/device.config'; +import { getWifiCredentials } from '../../config/wifi.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const deviceName = getDeviceName('waterDetector', 'WATER_DETECTOR_DEVICE'); +// 首页实际名匹配(待实测确认后缀);可用 env 覆盖 +const WATER_PATTERN = process.env.WATER_PATTERN || 'Water ?Leak ?Detector\\s+\\w+'; +const ADD_ANCHOR = onesAdd('waterDetector', getModelName('waterDetector', 'WATER_DETECTOR_DEVICE')); + +describe('Water Detector Connect - 添加水浸设备(串口配对 + WiFi配网)', () => { + let driver: DeviceDriver; + let reporter: TestReporter; + + beforeAll(async () => { + driver = createDriver(); + await driver.createSession(); + reporter = new TestReporter('WaterDetector_Connect', driver.platform.toUpperCase()); + }); + + afterAll(async () => { + reporter.generate(); + await driver.destroySession(); + }); + + it(`${ADD_ANCHOR} 通过BLE+WiFi添加水浸设备`, { timeout: 240000 }, async () => { + const start = Date.now(); + const wifi = getWifiCredentials(); + try { + const existing = await findDeviceNameOnHomepage(driver, WATER_PATTERN); + if (existing) { + console.log(`${existing}已在首页,跳过重新添加`); + saveAddedDevice('waterDetector', existing); + reporter.record(`${ADD_ANCHOR} 添加水浸设备`, 'SKIP', Date.now() - start, `${existing}已存在, 无需重新添加`); + return; + } + + const result = await addDeviceWithSerialPairing(driver, { + categoryName: 'Water Leak Detector', + categoryScrollHint: 4, // 添加目录里约第 4 次下滑可见(提速) + deviceKeyword: deviceName, + relayDevice: 'water', // 继电器 ch45 长按 2s(config/relay.config) + registryCategory: 'waterDetector', + namePattern: WATER_PATTERN, + // 连接后:① 点 "Connect Device" 按钮(主链路 tapNext 在 Android 只点 Next,不点这个)启动连接 + // ② 进 Configure Wi-Fi 页(结构同 Hub,2 个 EditText)→ 复用 configureHubWifi 填 SSID/密码 + postConnectSteps: async (d) => { + const btn = await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect Device")') + || await d.findElementRaw('-android uiautomator', 'new UiSelector().text("Connect device")'); + if (btn) { await d.tapElement(btn); await sleep(2500); } + await configureHubWifi(d, wifi); + }, + connectionKeywords: [ + 'Connect Device', 'Configure Wi-Fi', 'Connecting', 'Discover device', + 'Pick a room', 'Done', 'added successfully', 'Initial Setup', + ], + }); + expect(result).toBe(true); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + reporter.record(`${ADD_ANCHOR} 添加水浸设备`, 'PASS', Date.now() - start, `${deviceName}添加成功(WiFi:${wifi.ssid}), 耗时${elapsed}s`); + } catch (e: any) { + const ss = await driver.screenshot().catch(() => ''); + reporter.record(`${ADD_ANCHOR} 添加水浸设备`, 'FAIL', Date.now() - start, e.message, ss); + throw e; + } + }); +}); diff --git a/utils/common/account-code.helper.ts b/utils/common/account-code.helper.ts new file mode 100644 index 0000000..a2559c1 --- /dev/null +++ b/utils/common/account-code.helper.ts @@ -0,0 +1,97 @@ +/** + * 临时邮箱取验证码 helper —— 用 mail.tm(免费、免预注册 API key,按需建邮箱拿 JWT)。 + * 替代已失效的旧 accountCode.py(mail.td 匿名接口已下线)。 + * + * 典型用法(注册/忘记密码/注销): + * const box = await createTempMailbox(); // { address, token } + * // …在 App 里用 box.address 注册、触发发码… + * const code = await getVerificationCode(box.token); // 轮询收件箱,正则取 6 位码 + */ + +const API = 'https://api.mail.tm'; + +export interface TempMailbox { + address: string; + password: string; + token: string; +} + +async function jget(url: string, token?: string): Promise { + const res = await fetch(url, { + headers: { accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + }); + if (!res.ok) throw new Error(`GET ${url} → HTTP ${res.status}`); + return res.json(); +} + +async function jpost(url: string, body: any): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`POST ${url} → HTTP ${res.status}`); + return res.json(); +} + +/** mail.tm 集合响应:accept: application/json 返回普通数组;application/ld+json 返回 {hydra:member}。两者都兼容。 */ +function asList(data: any): any[] { + if (Array.isArray(data)) return data; + return data?.['hydra:member'] || []; +} + +/** 用已有邮箱地址重新登录 mail.tm 取 token(邮箱持久,可复用已注册账号收码)。 */ +export async function getMailboxToken(address: string, password = 'Test123456!'): Promise { + const { token } = await jpost(`${API}/token`, { address, password }); + if (!token) throw new Error(`mail.tm token 获取失败: ${address}`); + return token; +} + +/** 生成短随机用户名(≤10,字母数字),拼当前可用域名,建临时邮箱并返回登录 token。 */ +export async function createTempMailbox(): Promise { + const domains = asList(await jget(`${API}/domains`)); + const domain = domains.find((d: any) => d.isActive)?.domain || domains[0]?.domain; + if (!domain) throw new Error('mail.tm 无可用域名'); + // 随机用户名:字母开头 + 随机串(避免 Date.now,用时间不可用时退随机) + const rand = Math.random().toString(36).slice(2, 10); + const address = `woan${rand}@${domain}`; + const password = 'Test123456!'; + await jpost(`${API}/accounts`, { address, password }); + const { token } = await jpost(`${API}/token`, { address, password }); + if (!token) throw new Error('mail.tm 未返回 token'); + console.log(`临时邮箱: ${address}`); + return { address, password, token }; +} + +/** + * 轮询收件箱取验证码。默认匹配 "code is XXXXXX" 或邮件正文里的 6 位数字。 + * @param token createTempMailbox 返回的 JWT + * @param opts.timeoutMs 总超时(默认 60s);opts.regex 自定义验证码正则(第 1 个捕获组为码) + */ +export async function getVerificationCode( + token: string, + opts: { timeoutMs?: number; regex?: RegExp } = {} +): Promise { + const timeoutMs = opts.timeoutMs ?? 60000; + const deadline = Date.now() + timeoutMs; + let lastErr = ''; + while (Date.now() < deadline) { + try { + const list = await jget(`${API}/messages`, token); + const msgs = asList(list); + if (msgs.length) { + const detail = await jget(`${API}/messages/${msgs[0].id}`, token); + const text: string = detail.text || (Array.isArray(detail.html) ? detail.html.join(' ') : detail.html || '') || detail.intro || ''; + const m = (opts.regex && text.match(opts.regex)) + || text.match(/code is[:\s]*([0-9A-Za-z]{4,8})/i) + || text.match(/\b(\d{6})\b/); + if (m && m[1]) { console.log(`验证码: ${m[1]}`); return m[1]; } + lastErr = `收到邮件但未匹配到验证码;正文片段: ${text.slice(0, 120)}`; + } + } catch (e: any) { + lastErr = e.message; + } + await new Promise((r) => setTimeout(r, 3000)); + } + throw new Error(`取验证码超时(${timeoutMs}ms)。${lastErr}`); +} diff --git a/utils/common/auth.helper.ts b/utils/common/auth.helper.ts new file mode 100644 index 0000000..55cde39 --- /dev/null +++ b/utils/common/auth.helper.ts @@ -0,0 +1,408 @@ +/** + * 平台账号 helper —— 登录 / 登出。Android 实测流程(2026-06): + * 登出: Profile tab → "Sign Out" → 确认框 "OK" + * 登录: 已登出 landing 顶部 "Sign in" → 表单(2 个 EditText:邮箱/密码)→ 填 → "Sign in" + * 表单上还有 "Sign up"(注册)、"Forgot Password"(忘记密码)入口。 + * + * 主账号凭据走 config/account.config(分平台,env 可覆盖)。 + * 注册/忘记密码/注销用临时邮箱(account-code.helper)。 + */ +import { DeviceDriver } from '../../drivers/types'; +import { AccountCredentials, getAccountCredentials } from '../../config/account.config'; +import { sleep } from './element.helper'; + +async function tapText(driver: DeviceDriver, t: string): Promise { + const el = driver.platform === 'android' + ? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`) + : await driver.findElementRaw('name', t); + if (el) { await driver.tapElement(el); return true; } + return false; +} + +async function tapId(driver: DeviceDriver, id: string): Promise { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().resourceId("com.theswitchbot.switchbot:id/${id}")`); + if (el) { await driver.tapElement(el); return true; } + return false; +} + +async function sourceHas(driver: DeviceDriver, kw: string): Promise { + return (await driver.getSource()).includes(kw); +} + +/** 轮询等待某文本出现(应对 Loading 遮罩/页面过渡),最多 timeoutMs。 */ +async function waitForText(driver: DeviceDriver, kw: string, timeoutMs = 8000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await sourceHas(driver, kw)) return true; + await sleep(800); + } + return false; +} + +/** 进入 Profile tab(底部导航 content-desc="Profile")。 */ +export async function goToProfile(driver: DeviceDriver): Promise { + // Profile 是底部 tab,仅在主页(底部导航可见)时点得到。控制/场景等子页残留时直接找 Profile 会落空, + // 导致登录态误判 → signInWithEmail 找不到表单(EditText=0)。故登录态子页先复位主页。 + // 但登出态/登录相关页(landing/表单)绝不能 goBack(BACK 会退出 App → 黑屏),此时跳过复位。 + const src0 = await driver.getSource().catch(() => ''); + const authOrLanding = /Forgot Password|Sign ?up|Create Account|Log ?in|Welcome|Continue with|Sign ?in/i.test(src0) + && !/Sign Out|Manage Accounts/.test(src0); + if (!authOrLanding) { + await driver.goBackToHomepage().catch(() => {}); + await sleep(700); + await driver.dismissPopupIfPresent().catch(() => {}); + } + const prof = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Profile")'); + if (prof) { await driver.tapElement(prof); await sleep(2000); } + // 清 Profile 页上覆盖的 Attention 框(安全等级低提示),否则挡住 Sign Out / 后续操作(EU 低安全账号登录后常驻) + await dismissEmailHintDialog(driver); +} + +/** 是否已登录:Profile 出现 Sign Out/Manage Accounts。注册/登录后常有 "Loading..." 遮罩致 source 暂空,需等其结算。 */ +export async function isLoggedIn(driver: DeviceDriver): Promise { + for (let i = 0; i < 10; i++) { + await driver.dismissPopupIfPresent().catch(() => {}); + let src = await driver.getSource(); + // 已在 Profile(登录态) + if (src.includes('Sign Out') || src.includes('Manage Accounts')) return true; + // 在登录表单/landing(登出态) + if (src.includes('Forgot Password') || (src.includes('Sign in') && src.includes('Sign up'))) return false; + // Loading 遮罩 / 空页 / 过渡:等一下重试,不急着导航 + if (src.trim().length < 80 || src.includes('Loading') || src.includes('加载')) { await sleep(2500); continue; } + // 其它页(如首页):去 Profile 确认 + await goToProfile(driver); + src = await driver.getSource(); + if (src.includes('Sign Out') || src.includes('Manage Accounts')) return true; + await sleep(2000); + } + return false; +} + +/** 登出:Profile → Sign Out → OK 确认。已登出则直接返回。 */ +export async function signOut(driver: DeviceDriver): Promise { + await goToProfile(driver); + if (!(await sourceHas(driver, 'Sign Out'))) { console.log('已是登出态,跳过 Sign Out'); return; } + await tapText(driver, 'Sign Out'); + await sleep(1500); + // 确认框:Are you sure you want to sign out? → OK(勿点 Cancel) + await tapText(driver, 'OK'); + await sleep(4000); + console.log('已登出'); +} + +/** + * 邮箱登录。从任意页进入:先到 Profile,点登出态 landing 的 "Sign in" 进表单,填邮箱/密码,提交。 + * @param creds 缺省用 config/account.config 的主账号 + */ +export async function signInWithEmail(driver: DeviceDriver, creds?: AccountCredentials): Promise { + const { email, password } = creds || getAccountCredentials(); + await goToLoginForm(driver); // 到登录表单(等 Forgot Password 出现) + + // 表单:2 个 EditText(邮箱[0]、密码[1])。Loading/过渡可能致暂时取不到 → 重试 + let eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); + for (let i = 0; i < 8 && eds.length < 2; i++) { + await sleep(1000); + eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); + } + if (eds.length < 2) throw new Error(`登录表单未找到邮箱/密码输入框(EditText=${eds.length})`); + await driver.clearText(eds[0]).catch(() => {}); + await driver.typeText(eds[0], email); + await sleep(400); + await driver.typeText(eds[1], password); + await sleep(400); + try { await driver.goBack(); } catch { /* 收起键盘 */ } + await sleep(500); + + // 提交(表单的 "Sign in" 按钮) + await tapText(driver, 'Sign in'); + await sleep(8000); + // 登录后可能弹 Attention 框(欧区区域提示 / 安全等级低)→ 有则点 Cancel 关掉,避免污染下个用例 + await dismissEmailHintDialog(driver); + console.log(`已登录: ${email}`); +} + +/** 从已登出态进入 Sign up(注册)页:landing → "Sign in"(进表单)→ "Sign up"(展开注册区)。已登录则先登出。 */ +export async function goToSignUpPage(driver: DeviceDriver): Promise { + await goToProfile(driver); + if (await sourceHas(driver, 'Sign Out')) { await signOut(driver); await goToProfile(driver); } + // landing(无 Forgot Password)→ 点 Sign in 进登录表单 + if (!(await sourceHas(driver, 'Forgot Password'))) { + await tapText(driver, 'Sign in'); + await waitForText(driver, 'Forgot Password', 8000); + } + // 登录表单 → 点 Sign up 展开注册区(出现 Enter email address) + for (let i = 0; i < 4 && !(await sourceHas(driver, 'Enter email address')); i++) { + await tapText(driver, 'Sign up'); + await waitForText(driver, 'Enter email address', 4000); + } +} + +/** + * 注册新账号(临时邮箱)。Android 实测流程(2026-06): + * Sign up 页 → 填邮箱 → 勾 3 个协议(cbAgreement/llAdult/cbSubEmail)→ Get Verification Code + * → (Terms 弹框 Agree)→ "Enter verification code" 页(单框)输入码 → 设密码页(2 框)→ Confirm。 + * 注:必须勾全 3 个协议,否则 Get Verification Code 不发码。验证码框是单个 EditText(非 6 格)。 + * @returns 新账号 { email, password } + */ +export async function registerAccount( + driver: DeviceDriver, + opts: { password?: string } = {} +): Promise<{ email: string; password: string; token: string }> { + const { createTempMailbox, getVerificationCode } = await import('./account-code.helper'); + const password = opts.password || 'Test1234a'; + const box = await createTempMailbox(); + + await goToSignUpPage(driver); + + // 填注册邮箱(hint=Enter email address) + const emailEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Enter email")'); + if (!emailEd) throw new Error('Sign up 页未找到邮箱输入框'); + await driver.clearText(emailEd).catch(() => {}); + await driver.typeText(emailEd, box.address); + await sleep(400); + try { await driver.goBack(); } catch { /* 收键盘 */ } + + // 勾全 3 个协议(缺一不发码) + for (const id of ['cbAgreement', 'llAdult', 'cbSubEmail']) { + const ok = await tapId(driver, id); + if (!ok) console.log(`WARN: 未勾到协议 ${id}`); + await sleep(300); + } + + // 发码 + 同意条款弹框 + await tapText(driver, 'Get Verification Code'); + await sleep(2000); + await tapText(driver, 'Agree'); // 首次会弹 Terms 弹框,点 Agree;无则忽略 + await sleep(2500); + + // 取码 + 输入(单框 editText) + const code = await getVerificationCode(box.token, { timeoutMs: 60000 }); + console.log(`注册 ${box.address} 验证码=${code}`); + const codeEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")') + || (await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'))[0]; + if (!codeEd) throw new Error('验证码输入框未找到'); + await driver.typeText(codeEd, code); + await sleep(2000); + try { await driver.goBack(); } catch { /* 收键盘 */ } + + // 验证码正确后通常自动进设密码页;若有推进按钮先点 + for (const b of ['Next', 'Confirm', 'Continue', '下一步']) { + if (await sourceHas(driver, b)) { await tapText(driver, b); await sleep(2000); break; } + } + + // 设密码页:2 个 EditText(新密码 / 确认密码)→ Confirm + await sleep(1500); + const pwds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); + if (pwds.length < 2) throw new Error(`设密码页输入框 <2(实际 ${pwds.length}),需查看该页结构`); + await driver.typeText(pwds[0], password); + await sleep(300); + await driver.typeText(pwds[1], password); + await sleep(300); + try { await driver.goBack(); } catch { /* 收键盘 */ } + for (const b of ['Confirm', 'Done', 'OK', 'Sign up', 'Submit', '确定', '完成']) { + if (await tapText(driver, b)) break; + } + await sleep(6000); + await driver.dismissPopupIfPresent(); + // 等注册后的 "Loading..." 遮罩结算到稳定登录态(Profile 有 Sign Out / 或到首页) + for (let i = 0; i < 8; i++) { + const src = await driver.getSource(); + if (src.includes('Sign Out') || src.includes('Manage Accounts') || src.includes('Add a Device') || src.includes('content-desc="Home"')) break; + await driver.dismissPopupIfPresent().catch(() => {}); + await sleep(2500); + } + console.log(`注册完成: ${box.address} / ${password}`); + return { email: box.address, password, token: box.token }; +} + +/** 进入登录表单(已登录则先登出)。等待表单出现(Forgot Password)。 */ +async function goToLoginForm(driver: DeviceDriver): Promise { + await goToProfile(driver); + if (await sourceHas(driver, 'Sign Out')) { await signOut(driver); await goToProfile(driver); } + // 失败注册/已存邮箱会弹 "Attention: Would you like to sign in with this email address?" → Cancel 回手动表单 + await dismissEmailHintDialog(driver); + if (!(await sourceHas(driver, 'Forgot Password'))) { + await tapText(driver, 'Sign in'); + await sleep(800); + await dismissEmailHintDialog(driver); + await waitForText(driver, 'Forgot Password', 8000); + } +} + +/** 关闭登录前后的 "Attention" 提示弹框(统一点 Cancel,避免挡住表单)。覆盖: + * ① "Would you like to sign in with this email address?"(已存邮箱/失败注册残留) + * ② "Your account's security level is low ... link another login method"(安全等级低提示,EU/低安全账号登录后弹) */ +async function dismissEmailHintDialog(driver: DeviceDriver): Promise { + const src = await driver.getSource(); + const hit = /sign in with this email|this email address|security level|Manage Accounts page|link another login/i.test(src); + if (hit) { + await tapText(driver, 'Cancel'); + await sleep(800); + } +} + +/** + * 忘记密码:登录表单 → "Forgot Password" → 输入邮箱 → Get Verification Code → 收码(单框)→ 设新密码(2框)→ Confirm。 + * Forgot Password 页无 3 个协议勾选(与注册不同)。用注册返回的 { email, token } 收重置码。 + * @returns 新密码 + */ +export async function forgotPassword( + driver: DeviceDriver, + params: { email: string; token: string; newPassword?: string } +): Promise { + const { getVerificationCode } = await import('./account-code.helper'); + const newPassword = params.newPassword || 'Reset1234a'; + + await goToLoginForm(driver); + // 点 Forgot Password 展开重置区,等 "Enter email address" 出现(链式登出后有 Loading,需重试) + for (let i = 0; i < 4 && !(await sourceHas(driver, 'Enter email address')); i++) { + await tapText(driver, 'Forgot Password'); + await waitForText(driver, 'Enter email address', 4000); + } + + // 输入邮箱 + const emailEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Enter email")'); + if (!emailEd) throw new Error('Forgot Password 页未找到邮箱输入框'); + await driver.clearText(emailEd).catch(() => {}); + await driver.typeText(emailEd, params.email); + await sleep(400); + try { await driver.goBack(); } catch { /* 收键盘 */ } + + // 发码(忘记密码无 3 协议;若意外有 Terms 弹框则 Agree) + await tapText(driver, 'Get Verification Code'); + await sleep(2000); + await tapText(driver, 'Agree'); + await sleep(2500); + + // 取重置码 + 输入(单框)。发码与取码并发,验证码页("Verify It's You")可能稍后才渲染 → 等框出现再输。 + const code = await getVerificationCode(params.token, { timeoutMs: 60000 }); + console.log(`忘记密码 ${params.email} 重置码=${code}`); + let codeEd: string | null = null; + for (let i = 0; i < 10 && !codeEd; i++) { + codeEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")'); + if (!codeEd) await sleep(1000); + } + if (!codeEd) throw new Error('重置码输入框未找到'); + await driver.typeText(codeEd, code); + await sleep(2000); + try { await driver.goBack(); } catch { /* 收键盘 */ } + for (const b of ['Next', 'Confirm', 'Continue', '下一步']) { + if (await sourceHas(driver, b)) { await tapText(driver, b); await sleep(2000); break; } + } + + // 设新密码(2 框)→ Confirm + await sleep(1500); + const pwds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); + if (pwds.length < 2) throw new Error(`新密码页输入框 <2(实际 ${pwds.length})`); + await driver.typeText(pwds[0], newPassword); + await sleep(300); + await driver.typeText(pwds[1], newPassword); + await sleep(300); + try { await driver.goBack(); } catch { /* 收键盘 */ } + for (const b of ['Confirm', 'Done', 'OK', 'Submit', '确定', '完成']) { + if (await tapText(driver, b)) break; + } + await sleep(6000); + await driver.dismissPopupIfPresent(); + console.log(`忘记密码完成,新密码: ${newPassword}`); + return newPassword; +} + +/** + * 注销(删除)当前登录账号。Android 实测流程(2026-06): + * Profile → Manage Accounts → "Delete Account"(进 Account Deletion Information 页) + * → 底部 destroyBto 按钮 → 确认框 "OK" → "Verify It's You" 码页(单框)输入码 + * → "Canceling your account..."(注销已提交,带 Withdraw 撤销入口)。 + * 注销码发到账号邮箱,需传该邮箱的 mail.tm token(getMailboxToken 取)。 + * @returns 是否到达 "Canceling your account" 成功页 + */ +export async function deleteAccount(driver: DeviceDriver, params: { token: string }): Promise { + const { getVerificationCode } = await import('./account-code.helper'); + + await goToProfile(driver); + await tapText(driver, 'Manage Accounts'); + await waitForText(driver, 'Delete Account', 8000); + await tapText(driver, 'Delete Account'); // 菜单项 → Account Deletion Information 页 + await waitForText(driver, 'Account Deletion Information', 8000); + + // 底部销毁按钮(destroyBto;与标题/菜单同名 "Delete Account",必须按 id 点) + if (!(await tapId(driver, 'destroyBto'))) throw new Error('未找到 destroyBto 销毁按钮'); + await sleep(2000); + // 确认框 "Delete Account | Cancel | OK" → OK + await tapText(driver, 'OK'); + + // 等 "Verify It's You" 码页渲染(确认后稍后才出)→ 取码输入 + let codeEd: string | null = null; + for (let i = 0; i < 12 && !codeEd; i++) { + codeEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")'); + if (!codeEd) await sleep(1000); + } + if (!codeEd) throw new Error('注销验证码输入框未找到'); + const code = await getVerificationCode(params.token, { timeoutMs: 60000 }); + console.log(`注销验证码=${code}`); + await driver.typeText(codeEd, code); + await sleep(1500); + try { await driver.goBack(); } catch { /* 收键盘 */ } + // 单框输满通常自动提交;若有确认按钮也点 + for (const b of ['Confirm', 'OK', 'Next', '确定']) { + if (await sourceHas(driver, `text="${b}"`)) { await tapText(driver, b); break; } + } + + const ok = await waitForText(driver, 'Canceling your account', 15000); + console.log(ok ? '注销已提交(Canceling your account)' : '未到注销成功页'); + return ok; +} + +/** + * 第三方(Google)登录。Android 实测流程(2026-06): + * 登录表单 → 点 "Sign in by a third-party account" 区的 lastThirdLogin 图标(上次用的=Google) + * → 拉起系统 Google 账号浮层(com.google.android.gms,需设备已登 Google 账号) + * → 点 continue_button("继续以…的身份登录")→ ~10s 回 App,登录成功(MainActivity)。 + * 注:lastThirdLogin 是"上次使用的第三方",本机为 Google;首次/无历史时入口图标可能不同。 + * 需设备预先登录了一个 Google 账号(账号浮层会直接给 one-tap 继续)。 + * @returns 是否登录成功 + */ +export async function signInWithGoogle(driver: DeviceDriver): Promise { + await goToLoginForm(driver); // 到登录表单(登出态) + + // 点第三方登录入口(Google = lastThirdLogin) + let g = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/lastThirdLogin'); + for (let i = 0; i < 5 && !g; i++) { await sleep(800); g = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/lastThirdLogin'); } + if (!g) { console.log('FAIL: 未找到第三方登录入口 lastThirdLogin'); return false; } + await driver.tapElement(g); + await sleep(4000); + + // 三星新流程:点 Google 后弹「选择账号 以继续使用SwitchBot」账号选择器 → 选第一个账号(任意)。 + // (旧机型为 one-tap continue_button 浮层;Google 记住授权时也可能直接登录。三者都兼容。) + for (let i = 0; i < 8; i++) { + const src = await driver.getSource(); + if (src.includes('Add a Device') || src.includes('content-desc="Home"') || src.includes('Manage Homes')) { + console.log(`Google 登录:已直接登录成功(无需选账号, +${i}s)`); + return true; + } + if (/选择账号|Choose an account|Choose account/i.test(src)) { + // 账号行:点第一个含 "@" 的邮箱文本即选中该账号 + const acct = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("@")'); + if (acct) { await driver.tapElement(acct); console.log('已选择 Google 账号(任意)'); await sleep(5000); break; } + } + if (await driver.findElementRaw('id', 'com.google.android.gms:id/continue_button')) break; // 旧 one-tap 流程 + await sleep(1000); + } + + // 选账号后:可能弹 continue_button 确认,或直接 OAuth 回调登录回 App。 + for (let i = 0; i < 10; i++) { + const src = await driver.getSource(); + if (src.includes('Add a Device') || src.includes('content-desc="Home"') || src.includes('Manage Homes')) { + console.log(`Google 登录:已回到 App (+${i}s)`); + break; + } + const cont = await driver.findElementRaw('id', 'com.google.android.gms:id/continue_button'); + if (cont) { await driver.tapElement(cont); console.log('点 Google continue_button'); await sleep(7000); break; } + await sleep(1000); + } + + await sleep(2000); + const ok = await isLoggedIn(driver); + console.log(`Google 第三方登录: ${ok}`); + return ok; +} diff --git a/utils/common/automation.helper.ts b/utils/common/automation.helper.ts new file mode 100644 index 0000000..4c5749a --- /dev/null +++ b/utils/common/automation.helper.ts @@ -0,0 +1,198 @@ +/** + * 自动化(Automation)通用流程 helper —— Android 实测(2026-06)。 + * 自动化 = 条件(Schedules 定时)+ 动作(复用 scene 的 selectSmartDeviceAction)。 + * 入口: Automations tab → Create(addBto)→ dismissGuideOverlay(首次引导) + * 条件: Add condition → Schedules → 时间轮设目标时间 → Save + * 动作: Add action → selectSmartDeviceAction(设备, 动作) + * 命名+Save → 到点触发 → 右上"..."→ Automation Logs 验证执行 → Edit Automation→Delete 删除 + * + * 时间轮(1080×2280):小时列 x≈425、分钟列 x≈650,选中带中心 y≈1908,1 格=上滑 95px(慢滑,无惯性)。 + */ +import { DeviceDriver } from '../../drivers/types'; +import { sleep } from './element.helper'; +import { dismissGuideOverlay } from './navigation.helper'; +import { selectSmartDeviceAction } from './scene.helper'; + +const HOUR_X = 425; +const MIN_X = 650; +const WHEEL_TOP = 1855; // 上滑终点(中心上方一格) +const WHEEL_MID = 1950; // 上滑起点 + +async function tapText(driver: DeviceDriver, t: string): Promise { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`); + if (el) { await driver.tapElement(el); return true; } + return false; +} +async function tapContains(driver: DeviceDriver, t: string): Promise { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`); + if (el) { await driver.tapElement(el); return true; } + return false; +} +async function tapId(driver: DeviceDriver, id: string): Promise { + const el = await driver.findElementRaw('id', `com.theswitchbot.switchbot:id/${id}`); + if (el) { await driver.tapElement(el); return true; } + return false; +} + +/** 在某一列(x)上滑 n 格(n>0 增大/上滑;n<0 减小/下滑)。1 格 95px。 */ +async function spinWheel(driver: DeviceDriver, x: number, n: number): Promise { + const up = n > 0; + for (let i = 0; i < Math.abs(n); i++) { + if (up) await driver.swipe(x, WHEEL_MID, x, WHEEL_TOP, 0.5); + else await driver.swipe(x, WHEEL_TOP, x, WHEEL_MID, 0.5); + await sleep(250); + } +} + +/** + * 设定 Schedules 时间轮到 targetH:targetM。先读 "Time HH:MM >" 行当前值作起点(新自动化默认 08:00), + * 打开时间轮 → 小时/分钟列各按差值就近方向滑动 → OK。 + */ +export async function setScheduleTime(driver: DeviceDriver, targetH: number, targetM: number): Promise { + const src = await driver.getSource(); + const m = src.match(/text="(\d{2}):(\d{2})"/); + const startH = m ? parseInt(m[1], 10) : 8; + const startM = m ? parseInt(m[2], 10) : 0; + + // 打开时间轮:点 "Time HH:MM" 行(用当前时间值文本定位) + const timeText = `${String(startH).padStart(2, '0')}:${String(startM).padStart(2, '0')}`; + if (!(await tapText(driver, timeText))) await tapContains(driver, 'Time'); + await sleep(1500); + + // 就近方向步数(小时 mod 24,分钟 mod 60) + const upH = ((targetH - startH) % 24 + 24) % 24; + const hSteps = upH <= 12 ? upH : -(24 - upH); + const upM = ((targetM - startM) % 60 + 60) % 60; + const mSteps = upM <= 30 ? upM : -(60 - upM); + + await spinWheel(driver, HOUR_X, hSteps); + await spinWheel(driver, MIN_X, mSteps); + await sleep(500); + await tapText(driver, 'OK'); + await sleep(1500); +} + +export interface AutomationConfig { + name: string; + deviceKeyword: string; // 动作设备(如 'Bot 14' / 'Curtain 89') + targetHour: number; // 定时小时(由调用方按设备当前时间+偏移算出) + targetMinute: number; + action?: string; // 优先动作文本(Turns off / Presses once 等) +} + +/** 创建定时自动化:条件=Schedules(targetH:targetM)+ 动作=设备动作。返回是否创建成功(出现在 My Automations)。 */ +export async function createAutomation(driver: DeviceDriver, cfg: AutomationConfig): Promise { + await driver.dismissPopupIfPresent().catch(() => {}); // 清启动 Tips/SmartThings 等弹框 + const auto = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")'); + if (auto) { await driver.tapElement(auto); await sleep(2500); } + await driver.dismissPopupIfPresent().catch(() => {}); + if (!(await tapId(driver, 'addBto'))) { console.log('FAIL: no Create(addBto)'); return false; } + await sleep(2500); + await dismissGuideOverlay(driver); // 首次引导浮层 + + // 条件:Add condition → Schedules → 时间轮 → Save。条件页可能稍慢/有引导,重试等待。 + let onCond = false; + for (let i = 0; i < 4 && !onCond; i++) { + await dismissGuideOverlay(driver); + await tapContains(driver, 'Add condition'); + await sleep(2000); + for (let j = 0; j < 6; j++) { + if ((await driver.getSource()).includes('Schedules')) { onCond = true; break; } + await sleep(800); + } + } + if (!onCond) { console.log('FAIL: 条件类型页无 Schedules'); return false; } + await tapText(driver, 'Schedules'); + await sleep(2000); + await setScheduleTime(driver, cfg.targetHour, cfg.targetMinute); + await tapText(driver, 'Save'); // Schedules 页 Save + await sleep(2500); + + // 动作:Add action → Smart Devices → 设备 → 动作 + await tapContains(driver, 'Add action'); + await sleep(2000); + if (!(await selectSmartDeviceAction(driver, cfg.deviceKeyword, cfg.action))) return false; + + // 命名 + Save + const ed = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Enter Automation name")'); + if (ed) { await driver.tapElement(ed); await sleep(300); await driver.typeText(ed, cfg.name); await sleep(300); try { await driver.goBack(); } catch { /**/ } } + await tapText(driver, 'Save'); + await sleep(3500); + await driver.dismissPopupIfPresent(); + + // 校验:回 My Automations 列表重试查名(Save 后可能先 Loading/停详情页,不能立即判) + let ok = false; + for (let i = 0; i < 6 && !ok; i++) { + const a = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")'); + if (a) { await driver.tapElement(a); await sleep(2000); } + ok = (await driver.getSource()).includes(cfg.name); + if (!ok) await sleep(2000); + } + console.log(`创建自动化 ${cfg.name}: ${ok}`); + return ok; +} + +/** 打开某自动化(确保在 My Automations 列表后再点名字,避免在日志页点到日志条目)。 */ +async function openAutomation(driver: DeviceDriver, name: string): Promise { + // 先退出可能的子页(Automation Logs / 详情),回到能看到底部导航的页 + for (let i = 0; i < 3; i++) { + const s = await driver.getSource(); + if (s.includes('Automation Logs') || s.includes('Scene Logs') || s.includes('Edit Automation View')) { + await driver.goBack(); await sleep(1000); + } else break; + } + const auto = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")'); + if (auto) { await driver.tapElement(auto); await sleep(2000); } + // 确认在 My Automations 列表页(非日志页)再点名字 + if (!(await driver.getSource()).includes('My Automations')) { + const auto2 = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")'); + if (auto2) { await driver.tapElement(auto2); await sleep(2000); } + } + return tapContains(driver, name); +} + +/** + * 验证自动化已执行:**自动化管理页(My Automations)右上角图标 → 菜单 "Automation Logs"** → 看是否有执行记录。 + * 注意:日志入口在管理页右上角(全局日志),不是自动化详情页。 + */ +export async function verifyAutomationExecuted(driver: DeviceDriver, name: string): Promise { + const auto = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")'); + if (auto) { await driver.tapElement(auto); await sleep(2500); } + // 右上角图标 → 菜单 + const ic = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/top_bar_right_icon'); + if (ic) await driver.tapElement(ic); else await driver.tap(1000, 180); + await sleep(1500); + if (!(await tapText(driver, 'Automation Logs'))) { console.log('未进入 Automation Logs'); return false; } + await sleep(2500); + const src = await driver.getSource(); + // 有该自动化名的记录,或日志非空(非 "No logs / 暂无") + const hasLog = src.includes(name) || (!src.includes('No logs') && !src.includes('暂无') && !src.includes('No Logs')); + console.log(`Automation Logs 有执行记录(${name}): ${hasLog}`); + return hasLog; +} + +/** 删除自动化:打开 → Edit Automation 的 Delete 按钮 → 确认框 Delete。返回是否已删除。 */ +export async function deleteAutomation(driver: DeviceDriver, name: string): Promise { + if (!(await openAutomation(driver, name))) return true; + await sleep(2000); + // Edit Automation 页底部/右上的 Delete + if (!(await tapText(driver, 'Delete'))) { + const del = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Delete")'); + if (del) await driver.tapElement(del); + } + // 等确认框("Cancel | Delete")出现,点对话框里的 Delete(最后一个) + for (let i = 0; i < 6; i++) { + const dels = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().text("Delete")'); + const src = await driver.getSource(); + if (src.includes('Cancel') && dels.length) { await driver.tapElement(dels[dels.length - 1]); break; } + await sleep(800); + } + await sleep(3000); + await driver.dismissPopupIfPresent(); + // gone 校验:回 My Automations 列表查名 + const a = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Automations")'); + if (a) { await driver.tapElement(a); await sleep(2000); } + const gone = !(await driver.getSource()).includes(name); + console.log(`删除自动化 ${name}: ${gone ? '已删' : '仍在'}`); + return gone; +} diff --git a/utils/common/curtain.helper.ts b/utils/common/curtain.helper.ts index 2b2c05f..1ebed3f 100644 --- a/utils/common/curtain.helper.ts +++ b/utils/common/curtain.helper.ts @@ -14,6 +14,8 @@ export interface CurtainAddOptions { calibrate?: boolean; /** Determine 位置每个方向的行进等待(ms),默认 15000。 */ moveWaitMs?: number; + /** Install 页内联重命名的目标名(curtain 类首页都叫 "Curtain XX",靠改名区分型号)。不传则不改名。 */ + renameTo?: string; } async function tapText(driver: DeviceDriver, txt: string): Promise { @@ -32,12 +34,20 @@ async function tapDesc(driver: DeviceDriver, desc: string): Promise { return false; } -/** 连接后第一步(curtain/curtain3 共用):Select Mode 选"单边开合" → Confirm。 */ +/** 按 resource-id 点击(Android)。base curtain 校准的 Move/Stop 是图标(ImageView),文字是不可点的标签,必须按 id 点图标。 */ +async function tapId(driver: DeviceDriver, id: string): Promise { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().resourceId("com.theswitchbot.switchbot:id/${id}")`); + if (el) { await driver.tapElement(el); return true; } + return false; +} + + async function selectModeOpenOneSide(driver: DeviceDriver): Promise { await waitForSource(driver, 'Select Mode', 60000).catch(() => {}); if (!(await tapDesc(driver, 'Open from one side'))) await tapText(driver, 'Open from one side'); await sleep(1000); - await tapText(driver, 'Confirm'); + // 推进按钮:curtain3 用 "Confirm",base curtain 用 "Next" → 试 Confirm,无则 Next + if (!(await tapText(driver, 'Confirm'))) await tapText(driver, 'Next'); } /** Curtain 3 连接后流程(含已验证的 Custom Calibration)。 */ @@ -48,6 +58,23 @@ export async function curtain3PostConnect(driver: DeviceDriver, opts: CurtainAdd await selectModeOpenOneSide(driver); await waitForSource(driver, 'Install', 30000).catch(() => {}); + + // Install 页含设备名(可编辑 EditText)+ Install / Install and calibrate later。 + // 按需在**本页内联重命名**(curtain/curtain3/2025 首页都叫 "Curtain XX",靠改名区分)→ 再继续校准。 + if (opts.renameTo) { + const nameEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")').catch(() => null); + if (nameEd) { + await driver.tapElement(nameEd); await sleep(500); + await driver.clearText(nameEd).catch(() => {}); + await driver.typeText(nameEd, opts.renameTo); await sleep(500); + try { await driver.goBack(); } catch { /* 收键盘 */ } + await sleep(700); + console.log(`curtain3: Install 页内联重命名为 "${opts.renameTo}"`); + } else { + console.log('curtain3: WARN Install 页未找到名称输入框,跳过重命名'); + } + } + if (!calibrate) { await tapText(driver, 'Install and calibrate later'); console.log('curtain3: 走 Install and calibrate later(跳过行程校验)'); @@ -105,14 +132,70 @@ export async function curtain3PostConnect(driver: DeviceDriver, opts: CurtainAdd } /** - * Base Curtain 连接后流程 —— 校准流程与 curtain3 **不同**:行程是 Move left → 等待 → Move right → 等待。 - * 该设备目前连接不上,校准流程待可连接后实测补全;当前默认走"稍后校准"。 + * Base Curtain 连接后流程(实测 2026-06-09 跑通)。流程与 curtain3 **不同**: + * Select Mode→Next → 命名页 Next → Installation Guide(选导轨)→ Skip Installation Guide + * → Calibrate → Manual Calibration → Confirm Orientation(勾 logo 朝向)→ Start Calibration + * → 2/3 **Move left**→Next → 3/3 **Move right**→Finish → 首页。 + * 关键:左限位在 2/3、右限位在 3/3,分两页设;同页又左又右会"Route too short"。 */ export async function curtainPostConnect(driver: DeviceDriver, opts: CurtainAddOptions = {}): Promise { + const moveWait = opts.moveWaitMs ?? 5000; // Move 后等电机行进 ~5s 再 Stop 记录限位 + + // ① Select Mode:选"单边开合"(默认已勾)→ Next await selectModeOpenOneSide(driver); - await waitForSource(driver, 'Install', 30000).catch(() => {}); - await tapText(driver, 'Install and calibrate later'); - if (opts.calibrate) { - console.log('curtain(base): 行程校验流程(Move left→Move right)待该设备可连接后实测补全;当前已走"稍后校准"'); + + // ② 命名页(默认名如 "Curtain 4D"):按需**本页内联改名**(同 curtain3,首页都叫 "Curtain XX",靠改名区分型号)→ Next + await waitForSource(driver, 'Name', 30000).catch(() => {}); + if (opts.renameTo) { + const nameEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")').catch(() => null); + if (nameEd) { + await driver.tapElement(nameEd); await sleep(500); + await driver.clearText(nameEd).catch(() => {}); + await driver.typeText(nameEd, opts.renameTo); await sleep(500); + try { await driver.goBack(); } catch { /* 收键盘 */ } + await sleep(700); + console.log(`curtain(base): 命名页内联重命名为 "${opts.renameTo}"`); + } else { + console.log('curtain(base): WARN 命名页未找到名称输入框,跳过重命名'); + } } + await tapText(driver, 'Next'); + + // ③ Installation Guide(选导轨类型 I/U/Rod)→ 底部 "Skip Installation Guide" + await waitForSource(driver, 'Installation Guide', 20000).catch(() => {}); + await tapText(driver, 'Skip Installation Guide'); + + // ④ Calibrate 页 → Manual Calibration(base curtain 此页无"稍后校准",按需求走手动) + await waitForSource(driver, 'Calibrate', 15000).catch(() => {}); + await tapText(driver, 'Manual Calibration'); + + // ⑤ Confirm Orientation:勾"logo 朝内" → Start Calibration + await waitForSource(driver, 'Confirm Orientation', 15000).catch(() => {}); + if (!(await tapText(driver, 'I confirm device logo is facing indoors.'))) + await tapDesc(driver, 'facing indoors'); + await sleep(800); + await tapText(driver, 'Start Calibration'); + + // ⑥ 2/3 设左限位:点 ivToLeft 图标开始左移 → 等 ~5s → 再点 ivToLeft 停止 → Next。 + // 注意:"Move left"/"Stop" 是不可点的文字标签,真正可点的是上方图标 ivToLeft(同一图标 toggle:点开始/再点停)。 + await waitForSource(driver, 'Move left', 20000).catch(() => {}); + await tapId(driver, 'ivToLeft'); + await sleep(moveWait); + await tapId(driver, 'ivToLeft'); // 再点同一图标 = Stop + await sleep(1500); + await tapText(driver, 'Next'); + + // ⑦ 3/3 设右限位:点 ivToRight 图标右移 → 等 ~5s → 再点 ivToRight 停止 → Finish + await waitForSource(driver, '3/3', 15000).catch(() => {}); + await tapId(driver, 'ivToRight'); + await sleep(moveWait); + await tapId(driver, 'ivToRight'); // 再点同一图标 = Stop + await sleep(1500); + await tapText(driver, 'Finish'); + + // ⑧ Calibrated successfully → Done + await waitForSource(driver, 'Calibrated successfully', 15000).catch(() => {}); + await tapText(driver, 'Done'); + await sleep(3000); + console.log('curtain(base): Manual Calibration 完成(图标toggle: ivToLeft→stop→Next→ivToRight→stop→Finish→Done)'); } diff --git a/utils/common/device-add.helper.ts b/utils/common/device-add.helper.ts index de63df9..db612ca 100644 --- a/utils/common/device-add.helper.ts +++ b/utils/common/device-add.helper.ts @@ -6,6 +6,7 @@ import { saveAddedDevice, clearAddedDevices } from './device-registry.helper'; export interface AddDeviceOptions { categoryName: string; + categoryContains?: boolean; // 品类入口默认全匹配;组合名入口(如 "Key Vision/Key Vision Pro")需设 true 用 textContains deviceKeyword: string; scanTimeout?: number; wizardButtons?: string[]; @@ -64,9 +65,20 @@ export async function findDeviceNameOnHomepage(driver: DeviceDriver, pattern: st return null; } -export async function selectDeviceCategory(driver: DeviceDriver, categoryName: string, scrollHint = 0): Promise { +export async function selectDeviceCategory(driver: DeviceDriver, categoryName: string, scrollHint = 0, allowContains = false): Promise { if (driver.platform === 'android') { - const find = () => driver.findElementRaw('-android uiautomator', `new UiSelector().text("${categoryName}")`); + // 默认**全匹配**(exact text);仅在 allowContains=true 时回退 textContains(用于组合名品类,如 "Keypad Vision/Keypad Vision Pro")。 + // 不要默认开 contains:会把 "Remote" 误匹配到 "Universal Remote" 等错误入口 → 流程卡死超时。 + const find = async () => { + const exact = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${categoryName}")`); + if (exact) return exact; + // 大小写不敏感的**全字**匹配(textMatches 用 Java 正则 (?i));仍是整串匹配,不会误匹配子串(如 Remote→Universal Remote)。 + const esc = categoryName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const ci = await driver.findElementRaw('-android uiautomator', `new UiSelector().textMatches("(?i)${esc}")`).catch(() => null); + if (ci) return ci; + if (allowContains) return await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${categoryName}")`); + return null; + }; // 品类网格异步加载:短轮询等其渲染(idle=1s 下通常很快;Meter 等非热门品类首屏不可见,少等即可) let el = await find(); for (let i = 0; i < 2 && !el; i++) { await sleep(400); el = await find(); } @@ -84,40 +96,62 @@ export async function selectDeviceCategory(driver: DeviceDriver, categoryName: s } if (!el) { console.log(`FAIL: no ${categoryName} option`); return false; } if (scrollHint <= 1 && swipes) console.log(`[hint] ${categoryName} 实际滑动 ${(scrollHint || 0) + swipes} 次找到(可设为该品类 scrollHint 提速)`); + // 防误点:元素若贴近屏幕底部(系统返回键/手势区,屏高 2280),点其中心会命中返回键→退回。 + // 先小幅下滑把它抬起再点(如 Water Leak Detector 滑到底正好压在返回键上,导致选品类变成返回)。 + try { + const rect = await driver.getElementRect(el); + if (rect && rect.y + rect.height > 2000) { + await driver.scrollDown(400); await sleep(500); + const el2 = await find(); + if (el2) el = el2; + } + } catch { /* 取不到 rect 就直接点 */ } await driver.tapElement(el); - await sleep(1200); - // 点击品类后通常进入 免责声明/引导/扫描 页:只要离开了品类网格即视为成功 - const src = await driver.getSource(); - const leftGrid = !src.includes('Add Device') || src.includes('Disclaimers') || src.includes('Next') || - src.includes('Scanning') || src.includes('searching') || src.includes(categoryName); - if (!leftGrid) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } + // 点击品类后进入 免责/引导/扫描/连接 页(部分页标题仍为 "Add Device" 且不显示品类名, + // 如 Presence/Safety Alarm 的 "Press and hold...Connect Device" 引导页)→ 轮询等渲染,命中任一标志即视为已进入。 + let entered = false; + for (let t = 0; t < 6 && !entered; t++) { + await sleep(700); + const src = await driver.getSource(); + entered = src.includes('Disclaimer') || src.includes('Before Using') + || src.includes('Connect Device') || src.includes('Press and hold') || src.includes('press the device') + || src.includes('indicator light') || src.includes('Scanning') || src.includes('searching') + || src.includes('Next') || src.includes(categoryName) || !src.includes('Add Device'); + } + if (!entered) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } return true; } - // iOS - scroll to reveal category, check visibility, then click - for (let i = 0; i < 8; i++) { - let el = await driver.findElementRaw('predicate string', - `name == "${categoryName}" AND type == "XCUIElementTypeStaticText"`); - if (!el) { - el = await driver.findElementRaw('predicate string', - `name == "${categoryName}" AND type == "XCUIElementTypeOther"`); - } - if (!el) { - el = await driver.findElementRaw('name', categoryName); + // iOS - 先在当前屏查找,找不到才滑动(每个位置只做 1~2 次 find,避免多次 WDA 往返拖慢) + const iosFind = async (): Promise => { + // 一条 predicate 覆盖 StaticText/Other/通用(name 或 label 精确) + let el = await driver.findElementRaw('predicate string', `name == "${categoryName}" OR label == "${categoryName}"`).catch(() => null); + if (!el && allowContains) { + el = await driver.findElementRaw('predicate string', `name CONTAINS "${categoryName}" OR label CONTAINS "${categoryName}"`).catch(() => null); } + return el; + }; + for (let i = 0; i < 14; i++) { + let el = await iosFind(); if (el) { - const visible = await driver.getElementAttribute(el, 'visible'); + const visible = await driver.getElementAttribute(el, 'visible').catch(() => 'true'); if (String(visible) !== 'true' && String(visible) !== '1') { await driver.scrollDown(400); - await sleep(800); + await sleep(500); continue; } - await driver.clickElement(el); + // 防贴底误点:卡片太靠下(y>700/844,易被底部栏挡或点到边缘)→ 先小幅上滑抬起,重取再点 + const rect = await driver.getElementRect(el).catch(() => null); + if (rect && rect.y > 700) { + await driver.scrollDown(260); await sleep(600); + const el2 = await iosFind(); if (el2) el = el2; + } + await driver.tapElement(el); // 真实坐标 tap(比 /click 稳) await sleep(3000); return true; } await driver.scrollDown(400); - await sleep(800); + await sleep(500); } console.log(`FAIL: no ${categoryName} option`); return false; @@ -178,7 +212,7 @@ export async function addDeviceViaBLE(driver: DeviceDriver, options: AddDeviceOp if (!addDevicePage) { console.log('FAIL: cannot navigate to Add Device'); return false; } // Step 2: Select device category - const catSelected = await selectDeviceCategory(driver, categoryName); + const catSelected = await selectDeviceCategory(driver, categoryName, 0, !!options.categoryContains); if (!catSelected) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } // Optional: product-specific pre-select steps @@ -207,8 +241,22 @@ export async function addDeviceViaBLE(driver: DeviceDriver, options: AddDeviceOp } } - // Step 5: Wait for connection - const connected = await waitForConnection(driver, connectionKeywords); + // Step 5: Wait for connection(连接失败时点 App 的"重试"再等,最多重试 2 次) + let connected = await waitForConnection(driver, connectionKeywords); + for (let r = 1; !connected && r <= 2; r++) { + let retryBtn: string | null = null; + for (const t of ['Retry', '重试', 'Try Again', 'Try again', 'Reconnect', 'Reconnect device', 'Connect again', 'Connect Again']) { + retryBtn = driver.platform === 'android' + ? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null) + : await driver.findElementRaw('name', t).catch(() => null); + if (retryBtn) break; + } + if (!retryBtn) break; // 当前页没有"重试"按钮,放弃 + console.log(`连接失败,点击重试(第 ${r}/2 次)`); + await driver.tapElement(retryBtn); + await sleep(3000); + connected = await waitForConnection(driver, connectionKeywords); + } if (!connected) { console.log('FAIL: connection timeout'); return false; } // Step 5.5: Post-connection steps (e.g. WiFi setup for cameras) @@ -240,7 +288,8 @@ export async function addDeviceViaBLE(driver: DeviceDriver, options: AddDeviceOp } export interface SerialPairingAddOptions { - categoryName: string; // 添加目录里的品类/产品名(如 'Meter' / 'Remote') + categoryName: string; + categoryContains?: boolean; // 品类入口默认全匹配;组合名入口(如 "Key Vision/Key Vision Pro")需设 true 用 textContains // 添加目录里的品类/产品名(如 'Meter' / 'Remote') categoryScrollHint?: number; // 该品类在添加目录里大概滑动几次可见(提速:先快滑 hint-1 次再校验);日志会打印实测值 deviceKeyword: string; // 首页校验关键字(如 'Meter 3M') relayDevice: string; // config/relay.config 的设备 key(如 'meter' / 'remote') @@ -250,6 +299,7 @@ export interface SerialPairingAddOptions { pairRetries?: number; // 配对未触发(按完没连上)时的最大尝试轮数,默认 3 pairPollIters?: number; // 每轮检测轮询次数(窗口=次数×1.5s),默认 12(~18s);remote 检测慢需调大 repressOnRetry?: boolean; // 重试时是否重按继电器;默认 true。切换式配对键(如 remote 凸+凹,按一次进/再按退)须设 false——只按一次,重试只重点 Next + tapOtherPairing?: boolean; // 配对未自动触发时,点"其他配对方式/Other pairing methods"切到手动配对路径(部分 plug 自动配对不行,需手动) postConnectSteps?: (driver: DeviceDriver) => Promise; // 连接后专属流程(如 curtain 选模式+行程校验);提供则替代默认 navigateThroughWizard holdMs?: number; // 覆盖按键时长 disclaimerMaxNext?: number; // 到"引导页(按按键处)"前需点几次 Next(过免责声明),默认 1 @@ -257,13 +307,15 @@ export interface SerialPairingAddOptions { wizardButtons?: string[]; } -/** 点"下一步"类按钮。Android 用 text("Next");iOS 配对页按钮叫 "Connect device"(非 Next),故按名依次尝试。返回是否点到。 */ +/** 点"下一步/连接设备"类按钮。连接/引导页按钮文案因品类而异(Next / Connect Device / Connect Devices),依次尝试。返回是否点到。 */ async function tapNext(driver: DeviceDriver): Promise { if (driver.platform === 'android') { - const el = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); - if (!el) return false; - await driver.tapElement(el); - return true; + // 兼容 Next 与 Connect Device(部分品类如 Safety Alarm/Find Card 引导页是 "Connect Device(s)" 非 Next) + for (const t of ['Next', 'Connect Device', 'Connect Devices', 'Connect device']) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`); + if (el) { await driver.tapElement(el); return true; } + } + return false; } // iOS:不同页的推进按钮名不同(免责声明="Next"、配对页="Connect device"、连接失败页="Retry"),依次尝试 for (const name of ['Next', 'Connect device', 'Connect Device', 'Retry', 'Continue', 'Done']) { @@ -317,6 +369,7 @@ export async function addDeviceWithSerialPairing( pairRetries = 3, pairPollIters = 12, repressOnRetry = true, + tapOtherPairing = false, postConnectSteps, holdMs, disclaimerMaxNext = 1, @@ -327,7 +380,7 @@ export async function addDeviceWithSerialPairing( const addDevicePage = await navigateToAddPage(driver, 'Device'); if (!addDevicePage) { console.log('FAIL: cannot navigate to Add Device'); return false; } - const catSelected = await selectDeviceCategory(driver, categoryName, categoryScrollHint); + const catSelected = await selectDeviceCategory(driver, categoryName, categoryScrollHint, !!options.categoryContains); if (!catSelected) { console.log(`FAIL: cannot select category ${categoryName}`); return false; } // 过免责声明到引导页(无 Disclaimers 的设备自动跳过) @@ -366,6 +419,15 @@ export async function addDeviceWithSerialPairing( await sleep(800); } if (!(await tapNext(driver))) console.log('WARN: 引导页未找到 Next'); + // 部分 plug 自动配对不触发 → 点"其他配对方式"切到手动配对路径(opt-in) + if (tapOtherPairing) { + for (const t of ['其他配对方式', 'Other pairing methods', 'Other pairing method', 'Other Pairing Methods', 'Connect another way', 'Pair another way', '其他方式', '其他配对']) { + const el = driver.platform === 'ios' + ? (await driver.findElementRaw('predicate string', `label CONTAINS "${t}" OR name CONTAINS "${t}"`).catch(() => null)) + : (await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`).catch(() => null)); + if (el) { console.log(`点击"其他配对方式"(${t})切手动配对`); await driver.tapElement(el); await sleep(2500); break; } + } + } // 检测轮询:出现连接关键字/设备名即视为进配对成功。窗口 = pairPollIters×1.5s。 // remote 的 App BLE 检测延迟大且不稳(实测可达 16s+),需更长窗口(见 remote 用例 pairPollIters)。 for (let t = 0; t < pairPollIters && !started; t++) { @@ -410,8 +472,9 @@ export async function addDeviceWithSerialPairing( await driver.scrollDown(400); await sleep(600); } - // 首页没匹配上,但已从输入页拿到名字 → 仍记录并视为成功(连接成功/失败都会到输入名页) - if (capturedName) { saveAddedDevice(registryCategory, capturedName); return true; } + // 成功 = 设备**真出现在首页**(上面 loop 已覆盖 capturedName/keyword/pattern 在首页的情况)。 + // 注意:连接成功/失败都会经过"输入设备名"页,capturedName 有值≠添加成功 → 不能仅凭它判通过(否则配网失败也误判 PASS)。 + if (capturedName) console.log(`WARN: 捕获到名字"${capturedName}"但首页未出现该设备 → 判为未添加(配网/连接可能失败)`); return false; } diff --git a/utils/common/feedback.helper.ts b/utils/common/feedback.helper.ts index b6a2442..a5f060f 100644 --- a/utils/common/feedback.helper.ts +++ b/utils/common/feedback.helper.ts @@ -2,73 +2,83 @@ import { DeviceDriver } from '../../drivers/types'; import { sleep, waitForSource } from './element.helper'; import { extractTicketId, setSharedTicketId } from './zendesk.helper'; -export async function navigateToFeedback(driver: DeviceDriver): Promise { - if (driver.platform === 'android') { - const el = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Feedback")'); - if (!el) return false; - await driver.tapElement(el); - await sleep(2000); - return true; - } - - const el = await driver.findElementRaw('name', 'Feedback'); - if (!el) return false; - await driver.tapElement(el); - await sleep(2000); - return true; +async function tapText(driver: DeviceDriver, t: string): Promise { + const el = driver.platform === 'android' + ? await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`) + : await driver.findElementRaw('name', t); + if (el) { await driver.tapElement(el); return true; } + return false; } -export async function submitFeedback( - driver: DeviceDriver, - category: string, - description: string -): Promise { - // Skeleton - implementation depends on actual app UI - if (driver.platform === 'android') { - const catEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${category}")`); - if (!catEl) return false; - await driver.tapElement(catEl); - await sleep(1000); - - const textField = await driver.findElementRaw('class name', 'android.widget.EditText'); - if (textField) { - await driver.typeText(textField, description); - await sleep(500); - } - - const submitEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Submit")'); - if (!submitEl) return false; - await driver.tapElement(submitEl); - await sleep(2000); - return true; - } - - // iOS - const catEl = await driver.findElementRaw('name', category); - if (!catEl) return false; - await driver.tapElement(catEl); - await sleep(1000); - - const textFields = await driver.findElementsRaw('class name', 'XCUIElementTypeTextView'); - if (textFields.length > 0) { - await driver.typeText(textFields[0], description); - await sleep(500); - } - - const submitEl = await driver.findElementRaw('name', 'Submit'); - if (!submitEl) return false; - await driver.tapElement(submitEl); +/** Profile → Support → Feedback 页(我的反馈工单列表 + Create a New Ticket)。 */ +export async function navigateToFeedback(driver: DeviceDriver): Promise { + const prof = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Profile")'); + if (prof) { await driver.tapElement(prof); await sleep(2000); } + if (!(await tapText(driver, 'Support'))) return false; + await sleep(2000); + if (!(await tapText(driver, 'Feedback'))) return false; await sleep(2000); return true; } +/** + * 提交一条 feedback 工单(Android 实测 2026-06)。流程: + * Feedback 页 → Create a New Ticket → Question Type 滚轮(从 Product Issues 起第 typeIndex 个) + * → 填 Question Details → Submit → "My Feedback" 成功页(含 Ticket ID)。 + * + * Question Type 滚轮选项无障碍不可见,只能盲滑:**先重置到顶(Product Issues),再上滑 typeIndex 格**。 + * 顺序:0 Product Issues / 1 Order Tracking / 2 Buying Guide / 3 App issues / 4 Rapid Replacement / 5 B2B Enquiry。 + * 坐标按 1080×2280;换机型需调。 + * @returns 是否到达成功页 + */ +export async function submitFeedback( + driver: DeviceDriver, + opts: { details?: string; typeIndex?: number } = {} +): Promise { + const details = opts.details || '内测问题直接关闭插件热更校验'; + const typeIndex = opts.typeIndex ?? 3; // App issues + + if (!(await tapText(driver, 'Create a New Ticket'))) { + // 已无工单时按钮文案可能不同,兜底找 "Create" + if (!(await tapText(driver, 'Create'))) return false; + } + await waitForSource(driver, 'Question Type', 8000).catch(() => {}); + + // 选 Question Type:开 picker → 重置到顶 → 上滑 typeIndex 格 → Confirm + // 开选择器:优先点字段文字 "Please select"(跨分辨率稳),找不到再坐标兜底。 + if (!(await tapText(driver, 'Please select'))) { await driver.tap(540, 460); } + await sleep(1200); + // 类型轮选项无障碍不可见,只能盲滑;与时间轮(automation.helper spinWheel)同一套方式: + // 单格慢滑 ~95px、duration 0.5、无惯性(1 次=1 格)。列居中 x≈540、选中带中心 y≈2090。 + const COL_X = 540, MID = 2090, TOP = 1995; + for (let i = 0; i < 8; i++) { await driver.swipe(COL_X, TOP, COL_X, MID, 0.5); await sleep(250); } // 下滑到顶=Product Issues + await sleep(300); + for (let i = 0; i < typeIndex; i++) { await driver.swipe(COL_X, MID, COL_X, TOP, 0.5); await sleep(250); } // 上滑 N 格 + await sleep(300); + // Confirm:文字定位(实测在 y≈1714,旧硬编码 y1511 点空),坐标兜底。 + if (!(await tapText(driver, 'Confirm'))) { await driver.tap(947, 1714); } + await sleep(2000); + + // 填 Question Details(末个 EditText) + const eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); + if (!eds.length) return false; + await driver.typeText(eds[eds.length - 1], details); + await sleep(500); + try { await driver.goBack(); } catch { /* 收键盘 */ } + + // Submit + if (!(await tapText(driver, 'Submit'))) return false; + await sleep(4000); + + // 成功页:My Feedback / Ticket ID + return waitForSource(driver, 'Ticket ID', 8000).catch(() => false) as Promise; +} + /** * feedback 提交成功后,从结果页抓取工单号并持久化(供「获取RN插件 / 插件热更」用例消费)。 - * 返回工单号;未抓到返回 null。抓到即写共享文件 reports/.zendesk-ticket.json。 - * 工单号是用例间传值的变量,绝不写死——这里负责"产出",下游 resolveTicketId() 负责"取用"。 + * 返回工单号;未抓到返回 null。 */ export async function captureFeedbackTicketId(driver: DeviceDriver): Promise { - // 提交成功页可能需要一两秒渲染工单号 let ticketId: string | null = null; for (let i = 0; i < 3 && !ticketId; i++) { const source = await driver.getSource(); diff --git a/utils/common/hub.helper.ts b/utils/common/hub.helper.ts index a2e0be8..2e2cea5 100644 --- a/utils/common/hub.helper.ts +++ b/utils/common/hub.helper.ts @@ -16,11 +16,17 @@ const EDIT = 'new UiSelector().className("android.widget.EditText")'; export async function configureHubWifi(driver: DeviceDriver, creds?: WifiCredentials): Promise { const { ssid, password } = creds || getWifiCredentials(); - // 等 "Configure Wi-Fi" 页出现(文本判定,跨平台)。BLE 连接较慢(relay 后约 24s 连上、27s 出页),窗口要够长(~37s)。 + // 等配网页出现:标题(Configure Wi-Fi / Wi-Fi Settings 等)或 直接出现 ≥2 输入框(SSID+密码)即视为到达。 + // BLE 连接较慢(relay 后约 24s 连上、27s 出页),窗口要够长(~37s)。 let onWifiPage = false; - for (let i = 0; i < 25; i++) { + for (let i = 0; i < 40; i++) { const src = await driver.getSource(); - if (/Configure Wi-?Fi|Wi-?Fi password|配置 ?Wi-?Fi/i.test(src)) { onWifiPage = true; break; } + const titleHit = /Configure Wi-?Fi|Wi-?Fi Settings|Wi-?Fi password|配置 ?Wi-?Fi/i.test(src); + const edCount = driver.platform === 'android' + ? (await driver.findElementsRaw('-android uiautomator', EDIT)).length + : (await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeTextField" OR type == "XCUIElementTypeSecureTextField"')).length; + const fieldHit = edCount >= 2 && /Wi-?Fi|password|密码|2\.4\s?GHz/i.test(src); + if (titleHit || fieldHit) { onWifiPage = true; break; } await driver.dismissPopupIfPresent(); await sleep(1500); } @@ -34,23 +40,55 @@ export async function configureHubWifi(driver: DeviceDriver, creds?: WifiCredent try { const t0 = (await driver.getElementAttribute(eds[0], 'text')) || ''; if (/password|密码/i.test(t0)) { pwdEl = eds[0]; ssidEl = eds[1]; } } catch { /* 默认顺序 */ } await driver.clearText(ssidEl).catch(() => {}); await driver.typeText(ssidEl, ssid); await sleep(500); - await driver.typeText(pwdEl, password); await sleep(500); + // 输入 SSID 后键盘把页面顶起、遮住密码框 → 必须先收键盘才能输密码(goBack 在键盘弹起时只收键盘、不返回) try { await driver.goBack(); } catch { /* 收键盘 */ } - await sleep(500); + await sleep(700); + // 收键盘后重新定位密码框(布局变化),密码框通常是最后一个 EditText + let pwd2 = pwdEl; + try { + const eds2 = await driver.findElementsRaw('-android uiautomator', EDIT); + if (eds2.length) pwd2 = eds2[eds2.length - 1]; + } catch { /* 用原引用 */ } + await driver.typeText(pwd2, password); await sleep(500); + // 再收一次键盘,Next 才点得到 + try { await driver.goBack(); } catch { /* 收键盘 */ } + await sleep(600); } else { - // iOS:SSID/密码框是自绘控件、无障碍不可见 → 按坐标点 + 模拟键盘键入(/wda/keys)。 - // 坐标按 iPhone 14 Pro(393×852pt):SSID≈(118,358)、密码≈(118,443);换机型需调。 - // 重要:SSID 预填手机当前 WiFi(如 Deco),聚焦**不会**自动全选,必须先清空——双击选中该词再键入替换; - // 最后必须用键盘 "Return" 收起键盘,否则 Next 被键盘挡住点不到。 + // iOS:优先**元素法**(plug 等的 SSID=XCUIElementTypeTextField、密码=XCUIElementTypeSecureTextField,可定位)。 + // 找不到标准输入框(如 hub 自绘 UI、无障碍不可见)再退**坐标法**(/wda/keys)。 const d = driver as any; - await d.tap(118, 358); await sleep(800); // 聚焦 SSID - await d.doubleTap(118, 358); await sleep(700); // 双击选中预填值(单词) - await d.sendKeys(['\b']); await sleep(300); // 删除选中(退格) - await d.sendKeys([ssid]); await sleep(800); // 键入目标 SSID - await d.tap(118, 443); await sleep(800); // 聚焦密码(通常为空) - await d.sendKeys([password]); await sleep(800); - const ret = await driver.findElementRaw('name', 'Return').catch(() => null); - if (ret) { await driver.tapElement(ret); await sleep(1000); } // 收起键盘 + const ssidEl = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null); + const pwdEl = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => null); + if (ssidEl && pwdEl) { + // **关键:每个框即时重新 find 再 typeText**。填完 SSID 键盘弹出→界面树变→开头找的 pwd 元素 id 会 stale 失效, + // 导致密码 typeText 不生效(US 配网失败根因)。typeText 走 element/value=setValue(会自动聚焦,iOS 标准框可靠),框为空无需 clear。 + const fill = async (predicate: string, text: string) => { + const el = await driver.findElementRaw('predicate string', predicate).catch(() => null); + if (!el) { console.log(`WARN: 未找到输入框(${predicate})`); return; } + await driver.tapElement(el).catch(() => {}); // 聚焦(已验证有效:tap 聚焦 → setValue) + await sleep(400); + await driver.typeText(el, text); // element/value setValue(已验证可正确填入) + await sleep(700); + }; + await fill('type == "XCUIElementTypeTextField"', ssid); + await fill('type == "XCUIElementTypeSecureTextField"', password); + // 收键盘:iOS 键盘的 "Done"/"Return" 键(挡住下方 Next,必须先收;driver.tapElement 走 /wda/tap 真实点,有效) + for (const k of ['Done', 'Return', 'Go', 'Join', 'done', '完成']) { + const kb = await driver.findElementRaw('name', k).catch(() => null); + if (kb) { await driver.tapElement(kb); await sleep(900); break; } + } + } else { + // 坐标法(自绘 UI 的 hub,如旧机):坐标按 iPhone 14 Pro(393×852pt),SSID≈(118,358)、密码≈(118,443);换机型需调。 + // 重要:SSID 预填手机当前 WiFi,聚焦不会全选,必须先清空——双击选中再键入;最后用 "Return" 收键盘。 + await d.tap(118, 358); await sleep(800); + await d.doubleTap(118, 358); await sleep(700); + await d.sendKeys(['\b']); await sleep(300); + await d.sendKeys([ssid]); await sleep(800); + await d.tap(118, 443); await sleep(800); + await d.sendKeys([password]); await sleep(800); + const ret = await driver.findElementRaw('name', 'Return').catch(() => null); + if (ret) { await driver.tapElement(ret); await sleep(1000); } + } } console.log(`Hub WiFi 配网:SSID="${ssid}" 密码已填,提交...`); @@ -69,6 +107,7 @@ export async function configureHubWifi(driver: DeviceDriver, creds?: WifiCredent ]; const FAIL = ['Failed', 'failed', 'incorrect', 'wrong', 'Unable', '失败', '密码错误', 'try again', 'Try Again']; let reached = false; + let leftPage = 0; // 连续几次"已离开配网输入页"(提前结束用) for (let t = 0; t < 30; t++) { // 最多 ~90s 等配网过程 const src = await driver.getSource(); if (SUCCESS.some((k) => src.includes(k))) { console.log(`Hub 配网成功(到结果页, +${t * 3}s)`); reached = true; break; } @@ -81,13 +120,21 @@ export async function configureHubWifi(driver: DeviceDriver, creds?: WifiCredent const vis = (src.match(/text="([^"]+)"/g) || []).map((x) => x.replace(/text="|"/g, '')).filter(Boolean).slice(0, 8).join(' | '); console.log(` 配网进行中 +${t * 3}s: ${vis}`); } + // 效率优化:提交后离开配网输入页(标题/密码框消失)= 已进入配网过程,提前结束(交后续首页校验),不死等 90s。 + // 但"连接中/验证中"页(Connecting/Trying to connect)既非输入页也非成功页 → 不能算"已离开"(否则灯类慢连接被提前切断,设备没落账号),需继续等成功页。 + const onInputPage = /Wi-?Fi Settings|Configure Wi-?Fi|Enter password|配置 ?Wi-?Fi/i.test(src); + const connecting = /Connecting|Trying to connect|connecting to your router|Verifying|连接中|正在连接|验证中/i.test(src); + if (!onInputPage && !connecting && t >= 1) { + if (++leftPage >= 2) { console.log(`配网页已离开(+${t * 3}s),提前结束等待`); reached = true; break; } + } else { leftPage = 0; } await driver.dismissPopupIfPresent(); await sleep(3000); } if (!reached) console.log('WARN: 未等到 Hub 配网成功页(可能 WiFi 不可达/非2.4G/密码错,或过程超 90s)'); - // 到结果页后走完结尾向导(Start Using / Done / 房间等) + // 到结果页后走完结尾向导(Start Using / Done / 房间等)。 + // 补充:部分 plug(如 US/JP)配网成功后多一步"Save/保存"页(EU 无此步)→ 加进按钮列表,补差异不改原流程。 await driver.dismissPopupIfPresent(); - await navigateThroughWizard(driver, ['Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip', 'Next'], 8); + await navigateThroughWizard(driver, ['Save', '保存', 'Use now', 'Start Using', 'Done', 'Got it', 'OK', 'Skip', 'Next'], 8); } diff --git a/utils/common/index.ts b/utils/common/index.ts index 4b399e9..653550d 100644 --- a/utils/common/index.ts +++ b/utils/common/index.ts @@ -17,3 +17,9 @@ export * from './device-registry.helper'; export * from './qr.helper'; export * from './curtain.helper'; export * from './hub.helper'; +export * from './account-code.helper'; +export * from './auth.helper'; +export * from './automation.helper'; +export * from './message.helper'; +export * from './preferences.helper'; +export * from './test-resilience.helper'; diff --git a/utils/common/message.helper.ts b/utils/common/message.helper.ts new file mode 100644 index 0000000..362c342 --- /dev/null +++ b/utils/common/message.helper.ts @@ -0,0 +1,38 @@ +/** + * 消息中心 helper —— Profile 右上角 btnMsgCenter → Notifications(消息中心)页。 + */ +import { DeviceDriver } from '../../drivers/types'; +import { sleep } from './element.helper'; +import { goToProfile } from './auth.helper'; + +/** 打开消息中心:Profile → btnMsgCenter。返回是否到达 Notifications 页。 */ +export async function openMessageCenter(driver: DeviceDriver): Promise { + await goToProfile(driver); + await sleep(1000); + const btn = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/btnMsgCenter'); + if (!btn) { console.log('未找到 btnMsgCenter(消息中心入口)'); return false; } + await driver.tapElement(btn); + await sleep(2500); + // Notifications 页标志(标题/家庭筛选) + const src = await driver.getSource(); + const ok = src.includes('Notifications') || src.includes('All Homes') || src.includes('消息') || src.includes('通知'); + console.log(`消息中心已打开: ${ok}`); + return ok; +} + +/** + * 点消息中心列表第一条消息右侧箭头 → 跳转到内容/详情页。返回是否成功跳转(离开 Notifications 列表)。 + * 消息卡的跳转箭头是无 id/text 的 ViewGroup,appium getSource 读不到,故直接点坐标。 + * 1080×2280:首条消息箭头中心 ≈ (959, 751)。 + */ +export async function tapFirstMessage(driver: DeviceDriver): Promise { + await sleep(1500); // 等列表渲染 + const before = await driver.getSource(); + if (!before.includes('All Homes')) { console.log('未在消息列表页,无法跳转'); return false; } + await driver.tap(959, 751); // 第一条消息右侧跳转箭头 + await sleep(2500); + const jumped = !(await driver.getSource()).includes('All Homes'); // 离开列表 = 已跳内容页 + console.log(`点第一条消息箭头 → 跳转内容页: ${jumped}`); + return jumped; +} + diff --git a/utils/common/navigation.helper.ts b/utils/common/navigation.helper.ts index 8c38c8a..083365e 100644 --- a/utils/common/navigation.helper.ts +++ b/utils/common/navigation.helper.ts @@ -82,8 +82,16 @@ export async function navigateToAddPage( return false; } - // iOS - const addBtn = await driver.findElementRaw('name', 'Add'); + // iOS - 先稳健拿到 "Add" 按钮:找不到说明不在首页(被上个用例的添加弹层/页面占住)→ dismiss + 回 Home tab 重试 + let addBtn = await driver.findElementRaw('name', 'Add'); + for (let attempt = 0; attempt < 3 && !addBtn; attempt++) { + await driver.dismissPopupIfPresent(); + await ensureHomeTab(driver); // goBackToHomepage + 点 HomeScene tab + await driver.dismissPopupIfPresent(); + await sleep(800); + addBtn = await driver.findElementRaw('name', 'Add'); + if (!addBtn) console.log(`navigateToAddPage(iOS) 第${attempt + 1}次未找到 Add,dismiss+回首页重试...`); + } if (!addBtn) { console.log('FAIL: Add button not found'); return false; } if (type === 'Scene') { @@ -144,7 +152,7 @@ export async function navigateToManageScenes(driver: DeviceDriver): Promise { if (driver.platform === 'android') { for (let i = 0; i < maxSteps; i++) { @@ -158,10 +166,10 @@ export async function navigateThroughWizard( const btn = buttonNames.find(b => src.includes(`text="${b}"`)); if (btn) { const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${btn}")`); - if (el) { await driver.tapElement(el); await sleep(1200); continue; } + if (el) { await driver.tapElement(el); await sleep(1800); continue; } // 放慢:安装指引/测试验证页渲染较慢 } // 没有可点按钮:可能已完成或加载中,短等再判 - await sleep(700); + await sleep(1000); } return await driver.isOnHomepage(); } @@ -179,3 +187,37 @@ export async function navigateThroughWizard( } return true; } + +/** + * 关闭首次进入页面的引导浮层(coach-mark)。出现场景:首次进 创建自动化 / 创建场景 / 家庭管理 等页面, + * 会弹"Add what will trigger... [Next]" → "Advanced Options [Got it]" 这类教程浮层,拦截点击。 + * 按引导标记依次点 Next / Got it 关掉(最多几步)。无引导则立即返回。 + */ +export async function dismissGuideOverlay(driver: DeviceDriver): Promise { + // 引导专用安全按钮:只在引导/coach-mark 上出现,单独可点(无引导文字标记也关,如保存后收尾的 "Got it")。 + const SAFE = ['Got it', 'Got It', 'I Know', 'I know', 'Skip', '知道了', '我知道了', '跳过']; + // 引导文字标记:出现时才允许点「Next/下一步」(避免在正常页误推进)。 + const GUIDE = ['trigger this Automation', 'Add what will', 'Advanced Options', 'will trigger', + 'Swipe to', 'Tap to', 'Tap anywhere', 'tutorial', 'Step 1', 'Step 2', '引导', '教程']; + const NAV = ['Next', '下一步']; + let emptyReads = 0; + for (let i = 0; i < 12; i++) { + const src = await driver.getSource(); + let tapped = false; + // 1) 先点安全引导按钮(Got it/Skip/...) + for (const b of SAFE) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${b}")`); + if (el) { await driver.tapElement(el); tapped = true; break; } + } + // 2) 仅当确有引导标记时才点 Next/下一步(分步引导) + if (!tapped && GUIDE.some((g) => src.includes(g))) { + for (const b of NAV) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${b}")`); + if (el) { await driver.tapElement(el); tapped = true; break; } + } + } + // 多步 coach-mark:点完一张后下一张("第二个 Got it")可能稍后才渲染 → 容忍一次空读再退出。 + if (tapped) { emptyReads = 0; await sleep(1200); } + else { if (++emptyReads >= 2) break; await sleep(900); } + } +} diff --git a/utils/common/ones-anchor.helper.ts b/utils/common/ones-anchor.helper.ts index 4ea9986..eab0819 100644 --- a/utils/common/ones-anchor.helper.ts +++ b/utils/common/ones-anchor.helper.ts @@ -12,17 +12,32 @@ export interface DeviceAnchor { ctrlWifi?: string; // 15974 下的 step uuid } +// ── ONES 15975「蓝牙控制设备」step uuid → 步骤(共49步,正序,2026-06-11 从 ONES 实拉核对全部一致)── +// 1 6e4uZSVe Bot 2 5YEfmpJz Lock 3 774V8bND Curtain 4 NvpUv9Tt Meter报警 5 3Jm5FAdW Meter校温 +// 6 QfYLdj8s Meter校湿 7 GkkrZpaC Humidifier 8 TNhSAuUC Bulb 9 4poyiVwD StripLight +// 10 2528azD6 吸顶灯pro 11 V15M64cP 吸顶灯lte 12 2jSFfY4k PlugMini 13 UZCtZRPy MeterPlus报警 +// 14 9mheLMDJ MeterPlus校温 15 GSZv8BYm MeterPlus校湿 16 HrigrD2X Motion 17 A8C4BsCm Contact +// 18 3frMRDZj BlindTilt-V20 19 Qbm9VB72 BlindTilt-V12 20 QwnZJARA IOSensor报警 21 Ncg7BDeR IOSensor校温 +// 22 k6cqKqtZ IOSensor校湿 23 QqsnPMc3 Curtain3 24 CiFPEbNS Battery循环扇 25 LnPNEJmS Lock Pro +// 26 3PmZMk1s Humidifier2 27 JBszgxRS 无电池循环扇 28 SmzuHtWi RollerShade 29 7qxA1ajE 美规净化器 +// 30 R5PDp8BR 日规Table净化器 31 3wM6fW8W Curtain3-2025 32 JggzNXDo MeterPro报警 33 L967RGHa MeterPro校温 +// 34 8Wzaj5pB MeterPro校湿 35 3bYsmiZG MeterProCO2报警 36 NDqRQxVx CO2校温 37 DxKG9Acs CO2校湿 +// 38 3ob75cjf RGBWW灯带3 39 JVnRz7xY RGBWW落地灯 40 MKgLq1oy Relay1pm 41 A1TLxsFw Relay1 +// 42 PG62s725 Relay2pm开关 43 AafJ5tj5 Relay2pm覆盖 44 E5YRm4Pg GarageDoor 45 QAHNrXrm RGBICWW灯带 +// 46 AfxmoAEq RGBICWW落地灯 47 E2QCS8xy RGBIC霓虹钢丝 48 TSVKWcJ3 eu plug 49 XWmNA9UL Standing循环扇 +// 注:WiFi 控制超级用例为 15974,step uuid 不同(见各 ctrlWifi)。 + const ANCHORS: Record> = { curtain: { - 'Curtain 1A': { add: 15969, ctrlBle: '774V8bND', ctrlWifi: 'GPT3wJd9' }, - 'Curtain3 2B': { add: 40955, ctrlBle: 'QqsnPMc3', ctrlWifi: 'GYtvdMTW' }, - 'BlindTilt 3C': { add: 15946, ctrlBle: '3frMRDZj', ctrlWifi: '6gv1WLKE' }, + 'Curtain 1A': { add: 15969, ctrlBle: '774V8bND', ctrlWifi: 'GPT3wJd9' }, // step3 Curtain + 'Curtain3 2B': { add: 40955, ctrlBle: 'QqsnPMc3', ctrlWifi: 'GYtvdMTW' }, // step23 Curtain3 + 'BlindTilt 3C': { add: 15946, ctrlBle: '3frMRDZj', ctrlWifi: '6gv1WLKE' }, // step18 BlindTilt V20 // 'Roller Shade': { add: 191100, ctrlBle: 'SmzuHtWi', ctrlWifi: 'ScBpcSfJ' }, // 'Curtain3 2025': { add: 265933, ctrlBle: '3wM6fW8W', ctrlWifi: 'VbqWg59G' }, }, lock: { - 'Lock 6F': { add: 15970, ctrlBle: '5YEfmpJz', ctrlWifi: '7cEkbh4Y' }, - 'LockPro 7G': { add: 66694, ctrlBle: 'LnPNEJmS', ctrlWifi: 'Virku9zd' }, + 'Lock 6F': { add: 15970, ctrlBle: '5YEfmpJz', ctrlWifi: '7cEkbh4Y' }, // step2 Lock 开锁/解锁 + 'LockPro 7G': { add: 66694, ctrlBle: 'LnPNEJmS', ctrlWifi: 'Virku9zd' }, // step25 Lock Pro 开锁/解锁 // 以下变体 UI 相似:仅添加(控制必测无独立 step;Lock Ultra 为全新 UI,归新写) 'Lock Lite': { add: 304132 }, 'Lock & Kit': { add: 51440 }, @@ -46,8 +61,8 @@ const ANCHORS: Record> = { }, humidifier: { 'Humidifier 1K': { add: 15964, ctrlBle: 'GkkrZpaC', ctrlWifi: '8tx3Rg76' }, - // Humidifier2 控制必测是"绑定温湿度计",非简单开关 → 现有 control 用例不覆盖,仅锚添加 - 'Humidifier2 2L': { add: 74104 }, + // Evaporative Humidifier(口头"加湿器2")控制必测是"绑定温湿度计",非简单开关 → 现有 control 用例不覆盖,仅锚添加 + 'Evaporative Humidifier': { add: 74104 }, }, hub: { // hub 无控制必测,仅添加 @@ -109,6 +124,15 @@ export function onesAdd(cat: string, deviceName: string): string { return a?.add ? `[P0][ONES:${a.add}]` : ''; } +/** + * 平台/账号类必测项锚点(无设备维度),如 onesPlatform(15940) → `[P0][ONES:15940]`。 + * 已知平台 P0 编号:注册 15940 / 忘记密码 15941 / 登出 15943 / 第三方登录 15942 / 欧区账号登录 227290。 + * (普通邮箱登录、账号注销 暂无独立 P0 必测项。) + */ +export function onesPlatform(id: number): string { + return `[P0][ONES:${id}]`; +} + /** 当前设备 + 当前协议的控制锚点前缀,如 `[P0][ONES:15975#774V8bND][ble]`;无对应返回空串。 */ export function onesCtrl(cat: string, deviceName: string): string { const a = ANCHORS[cat]?.[deviceName]; diff --git a/utils/common/preferences.helper.ts b/utils/common/preferences.helper.ts new file mode 100644 index 0000000..7fef9b1 --- /dev/null +++ b/utils/common/preferences.helper.ts @@ -0,0 +1,89 @@ +/** + * 全局偏好设置(Profile → Preferences)。温度单位是**全局**设置,不在各设备功能页 —— 在此切换, + * 影响所有温度设备(Meter / Hub / Outdoor Meter 等)。用 driver 导航(不要 adb 手点自绘首页)。 + */ +import { DeviceDriver } from '../../drivers/types'; +import { sleep } from './element.helper'; +import { goToProfile } from './auth.helper'; + +/** 进入 Profile → Preferences 页。返回是否到达。 */ +export async function openPreferences(driver: DeviceDriver): Promise { + // 先回首页(详情页没有底部导航,goToProfile 会失败) + try { await driver.goBackToHomepage(); } catch { /* ignore */ } + await sleep(800); + await goToProfile(driver); + await sleep(1000); + const pref = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Preferences")') + || await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Preference")'); + if (!pref) { console.log('Profile 无 Preferences 入口'); return false; } + await driver.tapElement(pref); + await sleep(2000); + return true; +} + +/** + * 切换全局温度单位。unit='C'(摄氏)/'F'(华氏)。 + * Preferences 里温度单位可能是:① 行项 "Temperature Unit" 点进去选 Celsius/Fahrenheit;② 直接的 ℃/℉ 分段开关。 + * 两种都兼容尝试。返回是否切换成功。 + */ +export async function setTemperatureUnit(driver: DeviceDriver, unit: 'C' | 'F'): Promise { + if (!(await openPreferences(driver))) return false; + + const targets = unit === 'C' + ? ['Celsius', '°C', '℃'] + : ['Fahrenheit', '°F', '℉']; + + // 情况①:先点 "Temperature Unit" 行进入子页 + const row = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Temperature Unit")') + || await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Temperature")'); + if (row) { await driver.tapElement(row); await sleep(1500); } + + // 选目标单位(子页或当页的分段开关) + for (const t of targets) { + const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`); + if (el) { + await driver.tapElement(el); + await sleep(1500); + console.log(`温度单位已切到 ${unit}(命中 "${t}")`); + return true; + } + } + + // 没找到 → 打印 Preferences 当前文本,便于定位真实控件 + const src = await driver.getSource(); + const texts = (src.match(/text="[^"]+"/g) || []).filter(t => !/text=""/.test(t)).slice(0, 30).join(' | '); + console.log(`未找到温度单位控件(unit=${unit})。Preferences 可见文本: ${texts}`); + return false; +} + +/** + * 切换全局湿度类型(Profile → Preferences → "Humidity Type")。 + * type='relative'(相对湿度 %)/ 'absolute'(绝对湿度 g/m³)。 + * 选项文本:"Relative Humidity %" / "Absolute Humidity g/m³"。 + * 切换后**回读 Preferences 的 Humidity Type 行值**校验是否生效(功能页同时显示 %/g·m³ 无法区分)。 + * 返回是否切换并校验成功。 + */ +export async function setHumidityType(driver: DeviceDriver, type: 'relative' | 'absolute'): Promise { + if (!(await openPreferences(driver))) return false; + const row = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Humidity Type")'); + if (!row) { console.log('Preferences 无 Humidity Type 行'); return false; } + await driver.tapElement(row); + await sleep(1500); + + const optText = type === 'relative' ? 'Relative Humidity' : 'Absolute Humidity'; + const opt = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${optText}")`); + if (!opt) { console.log(`未找到湿度选项 "${optText}"`); return false; } + await driver.tapElement(opt); + await sleep(1500); + + // 校验:回到 Preferences,Humidity Type 行现值应为目标单位符号 + const src = await driver.getSource(); + const wantSym = type === 'relative' ? '%' : 'g/m³'; + const otherSym = type === 'relative' ? 'g/m³' : '%'; + // 该页 Area Unit=square meters、Temperature=°C,湿度行的值才是 %/g·m³;absolute 时出现 g/m³ 即生效 + const ok = type === 'absolute' + ? src.includes('g/m³') + : (src.includes('%') && !src.includes('g/m³')); + console.log(`湿度类型切到 ${type}(期望含 "${wantSym}"): ${ok}`); + return ok; +} diff --git a/utils/common/relay.helper.ts b/utils/common/relay.helper.ts index da38b3b..94ee292 100644 --- a/utils/common/relay.helper.ts +++ b/utils/common/relay.helper.ts @@ -80,3 +80,20 @@ export async function pressButtonsTogether( console.log(`relay: 同时按 ${device}.[${buttons.join('+')}] → 通道[${channels.join(',')}] ${ms}ms`); return channels; } + +/** 电源循环:断电(relayOff)→ 等 offMs → 通电(relayOn)。用于"关电源等N秒再开"类配对(吸顶灯 Pro 等灯类经电源开关配对)。 */ +export async function powerCycle( + device: string, + button: string, + offMs = 3000, + opts: RelayActionOptions = {} +): Promise { + const ch = resolveRelayButton(device, button); + await withController(opts, async (c) => { + await c.relayOff(ch); + await new Promise((r) => setTimeout(r, offMs)); + await c.relayOn(ch); + }); + console.log(`relay: 电源循环 ${device}.${button} → 通道 ${ch}(断电 ${offMs}ms 后通电)`); + return ch; +} diff --git a/utils/common/room.helper.ts b/utils/common/room.helper.ts index a47ab9f..661e242 100644 --- a/utils/common/room.helper.ts +++ b/utils/common/room.helper.ts @@ -1,5 +1,60 @@ import { DeviceDriver } from '../../drivers/types'; import { sleep, waitForSource } from './element.helper'; +import { goToProfile } from './auth.helper'; + +async function tapText(driver: DeviceDriver, t: string): Promise { + const el = driver.platform === 'android' + ? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`) + : await driver.findElementRaw('name', t); + if (el) { await driver.tapElement(el); return true; } + return false; +} + +/** + * 进入 Manage Rooms(房间管理)列表页:Profile → Manage Homes → 第一个 home → Rooms。 + * 坐标按 1080×2280(进第一个 home 行);换机型需调。 + */ +export async function goToManageRooms(driver: DeviceDriver): Promise { + await goToProfile(driver); + if (!(await tapText(driver, 'Manage Homes'))) return false; + await sleep(2500); + await tapText(driver, 'Got it'); // 可能的"邀请加入"提示弹框 + await sleep(500); + await driver.tap(540, 486); // 进第一个 home(行中心) + await sleep(2500); + if (!(await tapText(driver, 'Rooms'))) return false; + await sleep(2500); + return waitForSource(driver, 'Manage Rooms', 6000).catch(() => false) as Promise; +} + +/** 在 Manage Rooms 页创建房间:Create Room → 输入名 → OK。返回房间是否出现在列表。 */ +export async function createRoom(driver: DeviceDriver, roomName: string): Promise { + if (!(await tapText(driver, 'Create Room'))) return false; + await sleep(1500); + const ed = await driver.findElementRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); + if (!ed) return false; + await driver.typeText(ed, roomName); + await sleep(500); + await tapText(driver, 'OK'); + await sleep(2500); + const ok = (await driver.getSource()).includes(roomName); + console.log(`创建房间 ${roomName}: ${ok}`); + return ok; +} + +/** 删除房间(清理/恢复数据):点房间 → 详情页右上角删除图标(top_bar_right_icon)→ Attention 弹框 Confirm。返回房间是否已消失。 */ +export async function deleteRoom(driver: DeviceDriver, roomName: string): Promise { + if (!(await tapText(driver, roomName))) return false; // 进房间详情 + await sleep(2000); + const icon = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/top_bar_right_icon'); + if (icon) await driver.tapElement(icon); else await driver.tap(1018, 185); // 右上角删除 + await sleep(1500); + await tapText(driver, 'Confirm'); // Attention 确认框 + await sleep(2500); + const gone = !(await driver.getSource()).includes(roomName); + console.log(`删除房间 ${roomName}: ${gone ? '已删除' : '仍存在'}`); + return gone; +} export async function selectRoom(driver: DeviceDriver, roomName: string): Promise { if (driver.platform === 'android') { diff --git a/utils/common/scene.helper.ts b/utils/common/scene.helper.ts index a166e82..4febcef 100644 --- a/utils/common/scene.helper.ts +++ b/utils/common/scene.helper.ts @@ -1,6 +1,6 @@ import { DeviceDriver } from '../../drivers/types'; import { sleep, waitForElement, waitForSource } from './element.helper'; -import { ensureHomeTab, navigateToAddPage, navigateToManageScenes } from './navigation.helper'; +import { ensureHomeTab, navigateToAddPage, navigateToManageScenes, dismissGuideOverlay } from './navigation.helper'; export interface SceneConfig { name: string; @@ -13,6 +13,7 @@ export async function createScene(driver: DeviceDriver, config: SceneConfig): Pr const opened = await navigateToAddPage(driver, 'Scene'); if (!opened) return false; + await dismissGuideOverlay(driver); // 首进场景页可能有引导浮层,挡住 Add action if (driver.platform === 'android') { const addAction = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Add action")'); @@ -221,28 +222,18 @@ export async function verifySceneInLogs(driver: DeviceDriver, sceneName: string) if (driver.platform === 'android') { const navigated = await navigateToManageScenes(driver); if (!navigated) return false; - - // Find the scene in Manage Scenes list and tap to enter scene detail - let sceneEl: string | null = null; - for (let i = 0; i < 5; i++) { - sceneEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${sceneName}")`); - if (sceneEl) break; - await driver.scrollDown(200); - await sleep(800); - } - if (!sceneEl) return false; - await driver.tapElement(sceneEl); - await sleep(2000); - - // Tap top-right button to enter scene execution details/logs - const moreBtn = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/ivRight")'); - if (moreBtn) { - await driver.tapElement(moreBtn); - await sleep(2000); - } - + await sleep(1500); + // 场景日志入口:Manage Scenes 右上角有两个图标(左=Create,右=日志)。id 会命中 Create,故直接点最右图标坐标(1080×2280 约 1010,175)→ 菜单 "Scene Logs"。 + await driver.tap(1010, 175); + await sleep(1500); + const logsEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Scene Logs")'); + if (!logsEl) { console.log('未进入 Scene Logs 菜单'); return false; } + await driver.tapElement(logsEl); + await sleep(2500); const source = await driver.getSource(); - return source.includes('Success') || source.includes('success') || source.includes('Executed') || source.includes(sceneName); + const has = source.includes(sceneName); // 日志页含该场景名 = 已执行(记录) + console.log(`Scene Logs 有执行记录(${sceneName}): ${has}`); + return has; } // iOS: navigate to Manage Scenes @@ -271,3 +262,44 @@ export async function verifySceneInLogs(driver: DeviceDriver, sceneName: string) const source = await driver.getSource(); return source.includes('Success') || source.includes('success') || source.includes('Executed') || source.includes(sceneName); } + +/** + * 通用动作选择(场景/自动化共用):在"Add action 后的动作来源页"点 Smart Devices → 找设备 → 选动作。 + * 兼容 Bot 两模式(按压=Presses once / 开关=Turns off/Turns on)与其它设备动作。 + * 调用前:已点过 "Add action"(处于动作来源选择页)。 + * @param actionPref 优先尝试的动作文本(如 'Turns off' / 'Presses once');找不到按通用顺序兜底。 + */ +export async function selectSmartDeviceAction( + driver: DeviceDriver, + deviceKeyword: string, + actionPref?: string +): Promise { + const tap = async (sel: string, val: string) => { + const el = await driver.findElementRaw(sel, val); + if (el) { await driver.tapElement(el); return true; } + return false; + }; + if (!(await tap('-android uiautomator', 'new UiSelector().text("Smart Devices")'))) { + console.log('FAIL: no Smart Devices'); return false; + } + await sleep(2000); + // 找设备(滚动) + let dev: string | null = null; + for (let i = 0; i < 12 && !dev; i++) { + dev = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${deviceKeyword}")`); + if (!dev) { await driver.scrollDown(400); await sleep(700); } + } + if (!dev) { console.log(`FAIL: no device ${deviceKeyword}`); return false; } + await driver.tapElement(dev); + await sleep(2000); + // 选动作:优先 actionPref,再兜底 Presses once / Turns off / Turns on + const candidates = [actionPref, 'Presses once', 'Turns off', 'Turns on'].filter(Boolean) as string[]; + for (const a of candidates) { + if (await tap('-android uiautomator', `new UiSelector().text("${a}")`)) { + console.log(`选动作: ${a}`); + await sleep(2000); + return true; + } + } + console.log('FAIL: no action option'); return false; +} diff --git a/utils/common/test-resilience.helper.ts b/utils/common/test-resilience.helper.ts new file mode 100644 index 0000000..bd929e8 --- /dev/null +++ b/utils/common/test-resilience.helper.ts @@ -0,0 +1,35 @@ +/** + * 用例弹性钩子:上个用例「异常失败」后,下个用例执行前重启 App 复位, + * 避免一个用例把 App 留在脏状态(卡子页/残留弹框/中断流程)而级联拖垮同文件后续所有用例。 + * + * 用法(在 describe 内、beforeAll 之后调用;driver 在 beforeAll 里创建,故用 getter 延迟取): + * describe('...', () => { + * let driver; + * beforeAll(async () => { driver = createDriver(); await driver.createSession(); }); + * setupResilientHooks(() => driver); + * it(...); + * }); + */ +import { beforeEach, afterEach } from 'vitest'; +import { DeviceDriver } from '../../drivers/types'; + +export function setupResilientHooks(getDriver: () => DeviceDriver | undefined): void { + let lastFailed = false; + beforeEach(async () => { + const driver = getDriver(); + if (!driver || !lastFailed) return; + lastFailed = false; + console.log('上个用例失败 → 重启 App 复位,避免级联失败'); + try { + if (typeof driver.restartApp === 'function') await driver.restartApp(); + await driver.goBackToHomepage(); + await driver.dismissPopupIfPresent(); + } catch (e: any) { + console.log(`复位失败(不阻断后续): ${e?.message || e}`); + } + }); + afterEach((ctx: any) => { + // vitest 任务结果:失败则标记,下个用例前复位 + if (ctx?.task?.result?.state === 'fail') lastFailed = true; + }); +}