From d5c81826fbbcbdc9a19d8a0410b399f23c138182 Mon Sep 17 00:00:00 2001 From: woan <798680981@qq.com> Date: Tue, 9 Jun 2026 14:14:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=B2=E5=8F=A3=E9=85=8D=E5=AF=B9?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=93=BE=E8=B7=AF=20+=20=E5=A4=9A=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E8=BF=9E=E6=8E=A5=E7=94=A8=E4=BE=8B=20+=20soak/?= =?UTF-8?q?=E7=BB=A7=E7=94=B5=E5=99=A8=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设备添加: device-add.helper 串口配对主链路(addDeviceWithSerialPairing), 按平台设备名注册表(device-registry.helper),hub/curtain/qr 专用 helper - 新增多设备连接用例: remote、outdoor_meter、rn_plugin,刷新各品类 connect 用例 - 配置: relay.config(继电器通道/按键映射)、wifi.config(分平台配网凭据) - 脚本: 夜间 soak(android/ios)、reset-devices、relay 控制与报告、调试脚本 - 工具: serial_controller 串口协议、zendesk/feedback/ones-anchor helper、test-reporter - 杂项: 撤出追踪 .DS_Store / .claude 本地设置;.gitignore 忽略 docs/ test-plan/ test_must_case/(生成产物/单独维护) Co-Authored-By: Claude Opus 4.8 --- .DS_Store | Bin 6148 -> 0 bytes .claude/settings.local.json | 49 -- .gitignore | 7 + config/device.config.ts | 31 +- config/relay.config.ts | 108 +++ config/wifi.config.ts | 33 + docs/UI自动化测试计划.docx | Bin 51210 -> 0 bytes docs/generate_test_plan.py | 618 ------------------ drivers/android-driver.ts | 66 +- drivers/wda-driver.ts | 5 + global-setup.ts | 3 +- package-lock.json | 491 ++++++++++++++ package.json | 5 + prompts/must_test_conversion.md | 18 + 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-longpress-delete.ts | 43 ++ scripts/dump-product-catalog.ts | 46 ++ scripts/gen-combined.ts | 9 + scripts/gen-must-test-manifest.ts | 29 +- scripts/overnight-soak-ios.sh | 86 +++ scripts/overnight-soak.sh | 78 +++ scripts/relay-press-report.ts | 49 ++ scripts/relay.ts | 106 +++ scripts/reset-devices.ts | 34 + scripts/soak-report.ts | 270 ++++++++ test-plan/must-test.manifest.ts | 219 ------- test-plan/ones-writeback-params.json | 607 ----------------- tests/aihub/aihub_connect.test.ts | 2 +- .../air_condition_connect.test.ts | 2 +- tests/automation/automation_general.test.ts | 4 +- tests/automation/rn_plugin.test.ts | 73 +++ tests/bot/.DS_Store | Bin 6148 -> 0 bytes tests/bot/bot_connect.test.ts | 2 +- .../ceiling_light_connect.test.ts | 10 +- .../ceiling_light_control.test.ts | 4 +- tests/color_bulb/color_bulb_connect.test.ts | 2 +- tests/curtain/curtain_connect.test.ts | 55 +- tests/fan/fan_connect.test.ts | 10 +- tests/fan/fan_control.test.ts | 4 +- tests/hub/hub_connect.test.ts | 51 +- tests/humidifier/humidifier_connect.test.ts | 2 +- tests/keypad/keypad_connect.test.ts | 10 +- tests/lock/lock_connect.test.ts | 2 +- tests/meter/meter_connect.test.ts | 31 +- tests/meter/outdoor_meter_connect.test.ts | 75 +++ tests/osc/osc_connect.test.ts | 2 +- tests/plug/plug_connect.test.ts | 2 +- tests/remote/remote_connect.test.ts | 77 +++ tests/robot/robot_connect.test.ts | 2 +- tests/strip_light/strip_light_connect.test.ts | 10 +- tests/strip_light/strip_light_control.test.ts | 4 +- utils/common/curtain.helper.ts | 118 ++++ utils/common/device-add.helper.ts | 431 +++++++++++- utils/common/device-registry.helper.ts | 53 ++ utils/common/feedback.helper.ts | 23 + utils/common/hub.helper.ts | 93 +++ utils/common/index.ts | 7 + utils/common/navigation.helper.ts | 115 ++-- utils/common/ones-anchor.helper.ts | 50 +- utils/common/qr.helper.ts | 85 +++ utils/common/relay.helper.ts | 82 +++ utils/common/serial_controller.ts | 194 ++++++ utils/common/zendesk.helper.ts | 136 ++++ utils/test-reporter.ts | 44 +- utils/wda-helper.ts | 5 + 70 files changed, 3514 insertions(+), 1656 deletions(-) delete mode 100644 .DS_Store delete mode 100644 .claude/settings.local.json create mode 100644 config/relay.config.ts create mode 100644 config/wifi.config.ts delete mode 100644 docs/UI自动化测试计划.docx delete mode 100644 docs/generate_test_plan.py create mode 100644 scripts/debug-add-page.ts create mode 100644 scripts/debug-add-shot.ts create mode 100644 scripts/debug-bot-scan.ts create mode 100644 scripts/debug-curtain-add.ts create mode 100644 scripts/debug-curtain3-cal.ts create mode 100644 scripts/debug-find-category.ts create mode 100644 scripts/debug-longpress-delete.ts create mode 100644 scripts/dump-product-catalog.ts create mode 100644 scripts/gen-combined.ts create mode 100644 scripts/overnight-soak-ios.sh create mode 100644 scripts/overnight-soak.sh create mode 100644 scripts/relay-press-report.ts create mode 100644 scripts/relay.ts create mode 100644 scripts/reset-devices.ts create mode 100644 scripts/soak-report.ts delete mode 100644 test-plan/must-test.manifest.ts delete mode 100644 test-plan/ones-writeback-params.json create mode 100644 tests/automation/rn_plugin.test.ts delete mode 100644 tests/bot/.DS_Store create mode 100644 tests/meter/outdoor_meter_connect.test.ts create mode 100644 tests/remote/remote_connect.test.ts create mode 100644 utils/common/curtain.helper.ts create mode 100644 utils/common/device-registry.helper.ts create mode 100644 utils/common/hub.helper.ts create mode 100644 utils/common/qr.helper.ts create mode 100644 utils/common/relay.helper.ts create mode 100644 utils/common/serial_controller.ts create mode 100644 utils/common/zendesk.helper.ts diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index ce776c049dee09a38282c93224a405838533d5fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5T5OiO({YT3VK`cTClA@RJ?>*U%-eSRBA(m24l7)sXdfJ?)pN$h|lB9 z?zUj77Y`z324=tQ{A`#nVW$HCqB|b80O|nXpb{3UxO^kzCtZ<(wG8$Q52?Gv-u$k#Zq~r;#8cPbLTy(iI)wsaoQau zH}vaLsmP!22mVzUO#1b$GZklpFb)!(5Do(jxxEhKp_+8nI3A`t*E0^M>Qwvn-RZP- z)H!L$cDplc$mvPD*^q|^$Fo`0+1}efz34wh&#`(nTnhZ`v}{_O!y9gv%6jpVI8yNg zdMK-mr3R2v!AqX`8H~&TGr$b2Ed%!QbZTo`As5CBFay73fbIv0O6Xe54eF}{7d-+X zme8yP_cTjTj1ESRPdR?r|_STgusn<%>dsGsN%MC74 kaHCr>#!@TZLDhnGNgG7hVr~#EDEvo2)4&Te@TUxX0>r#h$N&HU diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index cf8b038..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(curl -s http://localhost:8100/status)", - "Bash(python3 -m json.tool)", - "Bash(xcrun simctl *)", - "Bash(idevice_id -l)", - "Bash(system_profiler SPUSBDataType)", - "Bash(xcodebuild -version)", - "Bash(tidevice list *)", - "Bash(curl -s -X POST http://localhost:8100/session -H 'Content-Type: application/json' -d '{\"capabilities\":{\"alwaysMatch\":{\"platformName\":\"iOS\",\"shouldUseSingletonTestManager\":false}}}')", - "Bash(python3 -c ' *)", - "Bash(curl -s -X POST http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/elements -H 'Content-Type: application/json' -d '{\"using\":\"predicate string\",\"value\":\"name CONTAINS \\\\\"摄像机Plus 3K\\\\\" AND type == \\\\\"XCUIElementTypeCell\\\\\"\"}')", - "Bash(curl -s http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/source)", - "Bash(curl -s -X POST http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/element/87000000-0000-0000-430D-000000000000/click -H 'Content-Type: application/json' -d '{}')", - "Bash(curl -s -X POST http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/wda/tap -H 'Content-Type: application/json' -d '{\"x\":195,\"y\":280}')", - "Bash(curl -s -X POST http://localhost:8100/session/01F3CC63-AC4B-49B7-9CEC-DCA8495C861A/elements -H 'Content-Type: application/json' -d '{\"using\":\"name\",\"value\":\"Features\"}')", - "Bash(curl -s -X POST http://localhost:8100/session/E0B85237-9A02-4880-AC53-249942845E57/wda/tap -H 'Content-Type: application/json' -d '{\"x\":195,\"y\":200}')", - "Bash(curl -s http://localhost:8100/session/E0B85237-9A02-4880-AC53-249942845E57/screenshot)", - "Bash(curl -s -X DELETE \"http://localhost:8100/session/E0B85237-9A02-4880-AC53-249942845E57\")", - "Bash(curl -s -X GET http://localhost:4723/sessions)", - "Bash(curl -s http://localhost:8100/session/C0178F7D-492D-4E16-9E50-A6B9AFC34937/source)", - "Bash(curl -s http://localhost:8100/session/__TRACKED_VAR__/source)", - "Bash(curl -s -X POST http://localhost:8100/session/FF243246-C365-4F90-8590-E42A5D724D45/element -H 'Content-Type: application/json' -d '{\"using\":\"name\",\"value\":\"More\"}')", - "Bash(curl -s -X GET https://sz.ones.cn/openapi/v1/team/98Q19ZsW/testcase/plans -H 'Content-Type: application/json' -H 'Ones-Auth-Token: CN-MGM2MmM4NmFjYjU0NDliZDU2MjlhN2YyZTEwM2ViMWM-pat_f8dc5cd57c9f450975d7c0e6147e9fd5' -w '\\\\nHTTP_CODE: %{http_code}\\\\n')", - "WebFetch(domain:developer.ones.cn)", - "Bash(curl -s 'https://sz.ones.cn/openapi/v2/testcase/libraries/cases?teamID=98Q19ZsW&testCaseLibraryID=HFcbrnoL' -H 'Authorization: Bearer CN-MjFiZTI1YmUwNWQ1NGYyZjU4YzMzZmNlNDEwNGY2OGQ-pat_914ebfdbd0ad400c6e7f610093b3d1e4')", - "Bash(curl -s 'https://sz.ones.cn/openapi/v2/testcase/libraries/cases?teamID=98Q19ZsW&testCaseLibraryID=8WUGnd2a' -H 'Authorization: Bearer CN-MjFiZTI1YmUwNWQ1NGYyZjU4YzMzZmNlNDEwNGY2OGQ-pat_914ebfdbd0ad400c6e7f610093b3d1e4')", - "Bash(curl -s 'https://sz.ones.cn/openapi/v2/testcase/libraries/cases?teamID=98Q19ZsW&library=HFcbrnoL' -H 'Authorization: Bearer CN-MjFiZTI1YmUwNWQ1NGYyZjU4YzMzZmNlNDEwNGY2OGQ-pat_914ebfdbd0ad400c6e7f610093b3d1e4')", - "Bash(curl -s 'https://sz.ones.cn/openapi/v2/testcase/libraries?teamID=98Q19ZsW&pageSize=50&cursor=8M5Xs9OUjhIxAAAAdGVzdGNhc2VfbGlicmFyeS1MRHJKbVZ5Sw==' -H 'Authorization: Bearer CN-MjFiZTI1YmUwNWQ1NGYyZjU4YzMzZmNlNDEwNGY2OGQ-pat_914ebfdbd0ad400c6e7f610093b3d1e4')", - "Bash(chmod +x /Users/woan/local/bin/ones)", - "Bash(/Users/woan/local/bin/ones config *)", - "Bash(/Users/woan/local/bin/ones graphql *)", - "mcp__figma__get_screenshot", - "Bash(/Users/woan/.local/bin/ones config *)", - "Bash(/Users/woan/local/bin/ones testcase *)", - "Bash(npx tsc *)", - "Bash(npx vitest *)", - "Bash(appium --version)", - "Bash(appium --port 4723 --allow-insecure chromedriver_autodownload)", - "Bash(echo \"Appium PID: $!\")", - "Bash(curl -s http://localhost:4723/status)", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('Appium ready:', d.get\\('value',{}\\).get\\('ready',False\\)\\)\")", - "Bash(adb devices *)", - "Bash(xcrun xctrace *)", - "Bash(PLATFORM=android npx vitest run tests/aihub/aihub_daily_report.test.ts)" - ] - } -} diff --git a/.gitignore b/.gitignore index c66a932..527af01 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ midscene_run/ .idea/ .DS_Store .claude/ + +# 旧 Appium/pytest 必测用例 + allure 报告产物(不入库) +test_must_case/ +# 测试计划/清单(本地生成,机器相关) +test-plan/ +# 文档(单独维护,不随代码提交) +docs/ diff --git a/config/device.config.ts b/config/device.config.ts index 06a2e74..e1e2b4b 100644 --- a/config/device.config.ts +++ b/config/device.config.ts @@ -1,3 +1,5 @@ +import { getAddedDevice } from '../utils/common/device-registry.helper'; + export interface DeviceCategoryConfig { devices: string[]; defaultDevice: string; @@ -9,11 +11,11 @@ export const DEVICE_CONFIG: Record = { defaultDevice: 'Bot 0F', }, camera: { - devices: ['摄像机Plus 3K'], + devices: ['摄像机Plus 3K', 'Pan Tilt', 'Pan Tilt Cam 2K', 'Pan Tilt Plus 2K', 'Pan Tilt Plus 3K', 'Indoor Cam'], defaultDevice: '摄像机Plus 3K', }, osc: { - devices: ['OSC 2K'], + devices: ['OSC 2K', 'Outdoor'], defaultDevice: 'OSC 2K', }, curtain: { @@ -21,7 +23,7 @@ export const DEVICE_CONFIG: Record = { defaultDevice: 'Curtain 1A', }, plug: { - devices: ['Plug 4D', 'PlugMini 5E'], + devices: ['Plug 4D', 'PlugMini 5E', 'Plug Mini (JP)'], defaultDevice: 'Plug 4D', }, lock: { @@ -29,7 +31,7 @@ export const DEVICE_CONFIG: Record = { defaultDevice: 'Lock 6F', }, hub: { - devices: ['Hub2 8H', 'HubMini 9I', 'HubMiniMatter 0J'], + devices: ['Hub2 8H', 'HubMini 9I', 'HubMiniMatter 0J', 'Hub Plus'], defaultDevice: 'Hub2 8H', }, humidifier: { @@ -37,7 +39,7 @@ export const DEVICE_CONFIG: Record = { defaultDevice: 'Humidifier 1K', }, meter: { - devices: ['Meter 3M', 'Outdoor Meter 4N'], + devices: ['Meter 3M', 'Meter Plus', 'Outdoor Meter 4N'], defaultDevice: 'Meter 3M', }, sensor: { @@ -45,7 +47,7 @@ export const DEVICE_CONFIG: Record = { defaultDevice: 'Contact Sensor 5O', }, ceilingLight: { - devices: ['CeilingLight 7Q'], + devices: ['CeilingLight 7Q', 'Ceiling Light pro'], defaultDevice: 'CeilingLight 7Q', }, colorBulb: { @@ -65,7 +67,7 @@ export const DEVICE_CONFIG: Record = { defaultDevice: 'WaterDetector 1U', }, keypad: { - devices: ['Keypad 2V'], + devices: ['Keypad 2V', 'Keypad Touch'], defaultDevice: 'Keypad 2V', }, remote: { @@ -99,6 +101,21 @@ export const DEVICE_CONFIG: Record = { }; export function getDeviceName(category: string, envVar?: string): string { + if (envVar && process.env[envVar]) { + return process.env[envVar]!; + } + // 添加成功后写入的实际设备名(进入功能页入口),优先于配置默认值 + const added = getAddedDevice(category); + if (added) return added; + return DEVICE_CONFIG[category]?.defaultDevice || category; +} + +/** + * 型号名(用于 ONES 锚点解析等"按型号"的场景):env 覆盖 > 配置默认值。 + * **不读注册表**——注册表是真机实际显示名(如 "Meter 0B"),而锚点表按型号名(如 "Meter 3M")建, + * 两者需区分:操作入口用 getDeviceName(实际名),锚点/型号用 getModelName(型号名)。 + */ +export function getModelName(category: string, envVar?: string): string { if (envVar && process.env[envVar]) { return process.env[envVar]!; } diff --git a/config/relay.config.ts b/config/relay.config.ts new file mode 100644 index 0000000..6cc2a6b --- /dev/null +++ b/config/relay.config.ts @@ -0,0 +1,108 @@ +/** + * 继电器板通道映射 + 物理按键动作配置(参数化,勿在脚本里写死通道号/时长)。 + * + * 每路继电器接到某设备的某个物理按键或电源。这里把「设备 → 按键名 → 通道号」以及 + * 「进入配对模式要按哪些键、按多久」集中配置,脚本只引用语义名(如 'remote' 的 'pairing')。 + * + * **按平台区分通道**:Android 与 iOS 是两套真机,同一设备的按键接在继电器板的不同通道上。 + * 通道号写成 { android, ios };动作逻辑(按哪些键/按多久)两端共用。平台取 process.env.PLATFORM + * (与 config/app.config、设备名注册表一致,默认 ios)。 + * + * 实测接线(Android,2026-06):ch33=meter 主键、ch34=remote 凸键、ch35=remote 凹键。 + * iOS 通道待接线后填入(留空则在 iOS 下调用会报清晰错误)。 + * 时长可被 env 覆盖:RELAY_HOLD_MS__(全大写)。 + */ + +/** 通道号:两端相同写数字;两端不同写 { android, ios }。 */ +export type ChannelSpec = number | { android?: number; ios?: number }; + +export interface RelayAction { + buttons: string[]; // 同时按下的按键名(引用本设备 buttons 的 key);单键即数组里一个 + holdMs: number; // 按住毫秒数 + desc?: string; +} + +export interface RelayDeviceConfig { + buttons: Record; // 按键名 → 通道(可按平台) + actions?: Record; +} + +export const RELAY_MAP: Record = { + meter: { + buttons: { main: { android: 33 /*, ios: <填iOS通道> */ } }, + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键进入添加模式' }, + }, + }, + remote: { + buttons: { + convex: { android: 34 /*, ios: <填> */ }, // 凸键 + concave: { android: 35 /*, ios: <填> */ }, // 凹键 + }, + actions: { + pairing: { buttons: ['convex', 'concave'], holdMs: 2000, desc: '同时按凸键+凹键 2s 进入添加模式(切换式:按一次进、再按退,勿重按)' }, + }, + }, + outdoor_meter: { + buttons: { main: { android: 36 /*, ios: <填> */ } }, + actions: { + pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式' }, + }, + }, + curtain3: { + buttons: { + power: { android: 37 /*, ios: <填> */ }, // 开关按钮 + track: { android: 38 /*, ios: <填> */ }, // 控制轨道按钮(控制用,不参与配对) + }, + actions: { + pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关按钮 2s 进入添加模式' }, + }, + }, + curtain: { + buttons: { power: { android: 39 /*, ios: <填> */ } }, // 开关按钮 + actions: { + pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关按钮 2s 进入添加模式' }, + }, + }, + hub: { + buttons: { main: { android: 43 /*, ios: <填> */ } }, // Hub Mini 主键 + actions: { + pairing: { buttons: ['main'], holdMs: 4000, desc: '按住主键 4s 进入添加模式(Hub Mini;指引页写3s,实测需4s)' }, + }, + }, +}; + +function currentPlatform(): 'android' | 'ios' { + return (process.env.PLATFORM || 'ios').toLowerCase() === 'android' ? 'android' : 'ios'; +} + +/** 解析某按键在当前平台的通道号。iOS 未单独配通道时,回退到 android(两端复用同一套设备/通道时用)。 */ +function channelOf(device: string, button: string): number { + const spec = RELAY_MAP[device]?.buttons?.[button]; + if (spec == null) throw new Error(`relay 配置无 '${device}.${button}'(config/relay.config.ts)`); + if (typeof spec === 'number') return spec; + const plat = currentPlatform(); + const ch = spec[plat] ?? spec.android; // iOS 缺省回退 android(先复用 Android 配置) + if (ch == null) throw new Error(`'${device}.${button}' 未配置 ${plat}(且无 android 回退)的通道(config/relay.config.ts)`); + return ch; +} + +/** 取设备某动作要触发的通道列表 + 时长(按当前平台解析通道;env 可覆盖时长)。 */ +export function resolveRelayAction( + device: string, + action: string +): { channels: number[]; holdMs: number; desc?: string } { + const dev = RELAY_MAP[device]; + if (!dev) throw new Error(`relay 配置无设备 '${device}'(config/relay.config.ts)`); + const act = dev.actions?.[action]; + if (!act) throw new Error(`设备 '${device}' 无动作 '${action}'`); + const channels = act.buttons.map((b) => channelOf(device, b)); + const envKey = `RELAY_HOLD_MS_${device.toUpperCase()}_${action.toUpperCase()}`; + const holdMs = process.env[envKey] ? parseInt(process.env[envKey] as string, 10) : act.holdMs; + return { channels, holdMs, desc: act.desc }; +} + +/** 取设备某按键在当前平台的通道号。 */ +export function resolveRelayButton(device: string, button: string): number { + return channelOf(device, button); +} diff --git a/config/wifi.config.ts b/config/wifi.config.ts new file mode 100644 index 0000000..2a7e0f6 --- /dev/null +++ b/config/wifi.config.ts @@ -0,0 +1,33 @@ +/** + * WiFi 配网凭据 —— 用于 Hub Mini 等需要连 WiFi 的设备添加流程。 + * + * 分平台配置(Android / iOS 可用不同 WiFi)。取值优先级: + * 平台专用 env(WIFI_SSID_ANDROID / WIFI_SSID_IOS) > 通用 env(WIFI_SSID) > 默认(woanmarket/woankeji) + * 密码同理(WIFI_PASSWORD_ANDROID / WIFI_PASSWORD_IOS > WIFI_PASSWORD > woankeji)。 + * + * 示例(.env 或 shell): + * WIFI_SSID_ANDROID=woanmarket WIFI_PASSWORD_ANDROID=woankeji + * WIFI_SSID_IOS=woanmarket_5g WIFI_PASSWORD_IOS=xxxx + * 当前两端默认相同(woanmarket/woankeji);后续要分开只需在 .env 设平台专用变量。 + */ +export interface WifiCredentials { + ssid: string; + password: string; +} + +const DEFAULT_SSID = 'woanmarket'; +const DEFAULT_PASSWORD = 'woankeji'; + +function pick(platformVar: string, genericVar: string, def: string): string { + return process.env[platformVar] || process.env[genericVar] || def; +} + +/** 取当前(或指定)平台的 WiFi 凭据。platform 缺省读 PLATFORM env(android/ios)。 */ +export function getWifiCredentials(platform?: 'android' | 'ios'): WifiCredentials { + const isIos = (platform || process.env.PLATFORM || 'android').toLowerCase() === 'ios'; + const suffix = isIos ? 'IOS' : 'ANDROID'; + return { + ssid: pick(`WIFI_SSID_${suffix}`, 'WIFI_SSID', DEFAULT_SSID), + password: pick(`WIFI_PASSWORD_${suffix}`, 'WIFI_PASSWORD', DEFAULT_PASSWORD), + }; +} diff --git a/docs/UI自动化测试计划.docx b/docs/UI自动化测试计划.docx deleted file mode 100644 index 32aec98ec35dc9275517fe3a954e0fd179ff10cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51210 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%0DFK8(?JNYoh9ERz2A>_`2?*e0{S-2}L# zlQoKrkV*ORDvXto0yDEH!o=5>=Ls2YpC;Qq7modVJ@kn)~qjY;IL`>E1nl6nf5cW$Q*t z5VX);f()h#r0~$j>El-Mi#`43XNtpqv655_ z^J{s21vwS^wuas>&nl)6K~#tQ$OnXWycFhvw+=h9$S&0=pZuZmEpq(+=i!+n^tu${+^OY+Z$KYi2hAfR{9_Gz^#vOQLHw2D|4QxuGz<+!P_YDkWCmpTvN-q8k1yCB z=$(JRdyMU!e$7PxX@0q~f^!5iw}AuzQ2((7z50UrfZqAn+|a?{f3contK`4r2LNpQ z!2WQa0t&=?dnXeX5QM8ONc;aXxAP@HX9qbS1Gysn&3T*1-{yZQ|8J2083olou)_)# z03cWm3V`w(5{=kD3zjbKHm1LpEB}napBqa4WA6(F=KP!SAKOd*iSy?=>3?vXivPj+ zuSJwU&Hr3@`H#6l`QPS$EW`YX@aICre-Pp-{zmw1S>jLoKWB~qV^3B6xBZ`U$bUlo zIsX0+1aZ^<9F6}8@aI7HKLFRi0sOCF@1IzIjvfDl^`rSuGXEJ#{)zJEQ0qS^(5-(* s<+p*?pZ0&|ga5J51&!$cZDPM?hl+AgpceGfq|pF|pym { /* 老版本 driver 不支持则忽略 */ }); await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId: APP_CONFIG.android.appPackage }); await new Promise(r => setTimeout(r, 3000)); } @@ -198,30 +204,39 @@ export class AndroidDriver implements DeviceDriver { async swipe(fromX: number, fromY: number, toX: number, toY: number, duration = 0.5): Promise { const durationMs = Math.round(duration * 1000); - await this.request('POST', `/session/${this.sessionId}/actions`, { + const body = { actions: [{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [ - { type: 'pointerMove', duration: 0, x: Math.round(fromX), y: Math.round(fromY) }, + { type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(fromX), y: Math.round(fromY) }, { type: 'pointerDown', button: 0 }, - { type: 'pointerMove', duration: durationMs, x: Math.round(toX), y: Math.round(toY) }, + { type: 'pointerMove', duration: durationMs, origin: 'viewport', x: Math.round(toX), y: Math.round(toY) }, { type: 'pointerUp', button: 0 }, ], }], - }); + }; + // UIA2 偶发 "Unable to perform W3C actions" 瞬时抖动 → 清理手势链后重试一次 + try { + await this.request('POST', `/session/${this.sessionId}/actions`, body); + } catch (e: any) { + if (!/W3C actions/i.test(e.message || '')) throw e; + await this.request('DELETE', `/session/${this.sessionId}/actions`).catch(() => {}); + await new Promise((r) => setTimeout(r, 500)); + await this.request('POST', `/session/${this.sessionId}/actions`, body); + } } async scrollDown(distance = 300): Promise { - const size = await this.getWindowSize(); + const size = await this.getWindowSizeCached(); const startX = size.width / 2; const startY = size.height / 2; await this.swipe(startX, startY, startX, startY - distance, 0.5); } async scrollUp(distance = 300): Promise { - const size = await this.getWindowSize(); + const size = await this.getWindowSizeCached(); const startX = size.width / 2; const startY = size.height / 2; await this.swipe(startX, startY, startX, startY + distance, 0.5); @@ -240,6 +255,13 @@ export class AndroidDriver implements DeviceDriver { return { width: result.width, height: result.height }; } + private _winSize: { width: number; height: number } | null = null; + /** 窗口尺寸缓存:滚动等高频调用不必每次往返查询(屏幕尺寸一个会话内不变)。 */ + async getWindowSizeCached(): Promise<{ width: number; height: number }> { + if (!this._winSize) this._winSize = await this.getWindowSize(); + return this._winSize; + } + async screenshot(): Promise { return await this.request('GET', `/session/${this.sessionId}/screenshot`); } @@ -301,22 +323,36 @@ export class AndroidDriver implements DeviceDriver { } async dismissPopupIfPresent(): Promise { - const source = await this.getSource(); - if (source.includes('android.app.Dialog') || source.includes('permission') || - source.includes('Upgrade') || source.includes("What's New") || - source.includes('AlertDialog') || source.includes('Please Note') || - source.includes('Restart') || source.includes('Got it')) { - const dismissTexts = ['OK', 'Got it', 'Close', 'Cancel', 'Confirm', 'Allow', - 'Later', 'Skip', 'Done', 'ALLOW', 'DENY', 'Not Now']; + // 关闭按钮(只点"否定/关闭"类,避免误点"更新/同意"推进);中英都覆盖 + const dismissTexts = [ + 'Cancel', 'Close', 'Later', 'Maybe Later', 'Not Now', 'No Thanks', 'No, Thanks', 'Dismiss', 'Skip', 'Ignore', + 'Got it', 'OK', 'I know', 'I Know', 'Done', 'Allow', 'ALLOW', 'DENY', 'While using the app', + '取消', '关闭', '稍后', '以后再说', '暂不', '暂不更新', '忽略', '跳过', '我知道了', '知道了', '好的', '允许', '不用了', + ]; + // 触发:对话框/常见弹框关键字(扩充:更新/评分/新功能/通知/试用/优惠/连接失败 等) + const triggers = [ + 'android.app.Dialog', 'AlertDialog', 'permission', 'Upgrade', 'Update', "What's New", 'New Feature', + 'Please Note', 'Restart', 'Got it', 'Rate', 'Review', 'Reminder', 'Notification', 'Subscribe', 'Trial', 'Welcome', 'Tips', + 'Failed to connect', 'connect to your device', 'Connection failed', 'Try Again', + '更新', '升级', '新功能', '评分', '评价', '提示', '通知', '订阅', '试用', '体验', '优惠', '连接失败', '请注意', '温馨提示', + ]; + let dismissed = false; + // 循环清掉叠加的多个弹框(最多 4 个) + for (let round = 0; round < 4; round++) { + const source = await this.getSource(); + if (!triggers.some(t => source.includes(t))) break; + let hit = false; for (const text of dismissTexts) { const el = await this.findElementRaw('-android uiautomator', `new UiSelector().text("${text}")`); if (el) { await this.tapElement(el); await new Promise(r => setTimeout(r, 1000)); - return true; + dismissed = true; hit = true; + break; } } + if (!hit) break; // 有触发词但没找到可点按钮,停止避免空转 } - return false; + return dismissed; } } diff --git a/drivers/wda-driver.ts b/drivers/wda-driver.ts index d10cecc..9048143 100644 --- a/drivers/wda-driver.ts +++ b/drivers/wda-driver.ts @@ -71,6 +71,11 @@ export class WDADriver implements DeviceDriver { return this.wda.typeText(elementId, text); } + /** 向聚焦输入框发送键盘按键(iOS 自绘输入框用)。 */ + async sendKeys(keys: string[]): Promise { + return this.wda.sendKeys(keys); + } + async clearText(elementId: string): Promise { return this.wda.clearText(elementId); } diff --git a/global-setup.ts b/global-setup.ts index 8fb6757..2302644 100644 --- a/global-setup.ts +++ b/global-setup.ts @@ -67,7 +67,7 @@ function checkAppium(): Promise { } export async function setup() { - TestReporter.clearSharedResults(); + if (!process.env.SOAK_MODE) TestReporter.clearSharedResults(); // soak 模式跨多次 run 累积,不每次清空 console.log(`[global-setup] Platform: ${platform}`); if (platform === 'android') { const ready = await checkAppium(); @@ -82,6 +82,7 @@ export async function setup() { } export function teardown() { + if (process.env.SOAK_MODE) return; // soak 模式:由 soak 脚本在全程结束后统一出一份报告 const p = process.env.PLATFORM || 'ios'; TestReporter.generateCombinedReport(`AIHub_${p.toUpperCase()}`); } diff --git a/package-lock.json b/package-lock.json index bc5ef52..988c589 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,16 @@ "@midscene/ios": "^1.8.1", "@types/node": "^25.8.0", "dotenv": "^17.4.2", + "jsqr": "^1.4.0", + "pngjs": "^7.0.0", + "serialport": "^13.0.0", "ts-node": "^10.9.2", "typescript": "^6.0.3" }, "devDependencies": { "@midscene/web": "^1.8.1", + "@types/pngjs": "^6.0.5", + "qrcode": "^1.5.4", "vitest": "^4.1.6" } }, @@ -1730,6 +1735,211 @@ "dev": true, "license": "MIT" }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz", + "integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "12.0.0", + "debug": "4.4.0", + "node-addon-api": "8.3.0", + "node-gyp-build": "4.8.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz", + "integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz", + "integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz", + "integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz", + "integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz", + "integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-13.0.0.tgz", + "integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-13.0.0.tgz", + "integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-13.0.0.tgz", + "integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz", + "integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz", + "integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", + "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, "node_modules/@silvia-odwyer/photon": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon/-/photon-0.3.3.tgz", @@ -1842,6 +2052,16 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2392,6 +2612,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2621,6 +2851,16 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -2720,6 +2960,13 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "dev": true, + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -3286,6 +3533,20 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -3746,6 +4007,12 @@ "jsonrepair": "bin/cli.js" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4019,6 +4286,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -4192,6 +4472,15 @@ "node": ">= 0.4.0" } }, + "node_modules/node-addon-api": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -4230,6 +4519,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4332,6 +4632,45 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -4375,6 +4714,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4467,6 +4816,15 @@ "node": ">=18" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -4599,6 +4957,90 @@ "node": ">=18" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -4648,6 +5090,13 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -4814,6 +5263,34 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/serialport": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz", + "integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "13.0.0", + "@serialport/parser-byte-length": "13.0.0", + "@serialport/parser-cctalk": "13.0.0", + "@serialport/parser-delimiter": "13.0.0", + "@serialport/parser-inter-byte-timeout": "13.0.0", + "@serialport/parser-packet-length": "13.0.0", + "@serialport/parser-readline": "13.0.0", + "@serialport/parser-ready": "13.0.0", + "@serialport/parser-regex": "13.0.0", + "@serialport/parser-slip-encoder": "13.0.0", + "@serialport/parser-spacepacket": "13.0.0", + "@serialport/stream": "13.0.0", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -4829,6 +5306,13 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5792,6 +6276,13 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 2b41980..8b228d4 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,16 @@ "@midscene/ios": "^1.8.1", "@types/node": "^25.8.0", "dotenv": "^17.4.2", + "jsqr": "^1.4.0", + "pngjs": "^7.0.0", + "serialport": "^13.0.0", "ts-node": "^10.9.2", "typescript": "^6.0.3" }, "devDependencies": { "@midscene/web": "^1.8.1", + "@types/pngjs": "^6.0.5", + "qrcode": "^1.5.4", "vitest": "^4.1.6" } } diff --git a/prompts/must_test_conversion.md b/prompts/must_test_conversion.md index 428c035..5d75cc7 100644 --- a/prompts/must_test_conversion.md +++ b/prompts/must_test_conversion.md @@ -175,6 +175,24 @@ reporter.record(`${ADD_ANCHOR} 添加窗帘设备`, ...); --- +## 8.1 特殊用例:插件热更 / 获取RN插件(平台 · Zendesk,工单号为变量) + +ONES **`48734 插件热更`**(库 `EPfZfC9Y` / 模块 其他功能 `Eeme1qVk`)。验证 app 上报的各 RN 插件包版本(Bundle_Version)。数据**不在** app 内,而在 **Zendesk 工单的评论**里——app 提交 feedback 后,工单评论会自动带一段环境信息(`App Version` / `JS_Version` / `Bundle_Version`)。 + +- 实现:`utils/common/zendesk.helper.ts`,测试 `tests/automation/rn_plugin.test.ts`(纯 API,无 driver,锚点 `[P0][ONES:48734]`)。 +- **工单号是变量,绝不写死**。三级解析(`resolveTicketId`):入参 > `env ZENDESK_TICKET_ID` > 上游用例写的共享文件 `reports/.zendesk-ticket.json`。 +- **上游传值链(确定方案)**:feedback 提交**成功页带工单号** → `captureFeedbackTicketId(driver)`(在 `feedback.helper.ts`)用 `extractTicketId()` 从页面源码抓号、`setSharedTicketId()` 落共享文件 → 插件热更用例 `getTicketRnPlugins()` 自动取用。这就是"app 提交 feedback 后保留工单号传给插件热更"。 +- 解析:`parseBundleVersion()` 把首尾相连的 `模块:版本` 串(如 `Home:default 1.0.49.0Scene:...`)拆成 `{模块: 版本}`;`parseDeviceInfo()` 同时抽 App/JS 版本;`extractTicketId()` 兼容「工单号/Ticket/Feedback ID/#数字/兜底长数字」多种页面文案。 +- 连接配置在 `.env`(gitignore):`ZENDESK_DOMAIN` / `ZENDESK_EMAIL` / `ZENDESK_TOKEN`(Basic 认证 `email/token:TOKEN`)。 +- 无工单号或未配 Zendesk → 用例 **SKIP**(无人值守不阻塞,不静默)。 + +```bash +# 手动验证(工单号 env 传入,不写死) +ZENDESK_TICKET_ID=<工单号> npx vitest run tests/automation/rn_plugin.test.ts +``` + +--- + ## 9. step 级结果回写 ONES(正确方法:GraphQL mutation) > ⚠️ **必须走 `ones graphql` mutation,不要用 curl 直连 REST `.../cases/update`。** diff --git a/scripts/debug-add-page.ts b/scripts/debug-add-page.ts new file mode 100644 index 0000000..6e48d55 --- /dev/null +++ b/scripts/debug-add-page.ts @@ -0,0 +1,60 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; +import { sleep } from '../utils/common/element.helper'; +import { enterPairingMode } from '../utils/common/relay.helper'; + +// Meter add:选品类 → 过 Disclaimers → Add Meter 页按继电器 → 向下滑动找设备入口 +async function main() { + const device = process.argv[2] || 'meter'; + const category = process.argv[3] || 'Meter'; + const keyword = process.argv[4] || 'Meter 3M'; + const driver = createDriver(); + await driver.createSession(); + try { + await navigateToAddPage(driver, 'Device'); + await sleep(2500); + const dump = async (label: string) => { + const src = await driver.getSource(); + const t = Array.from(src.matchAll(/text="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean); + const d = Array.from(src.matchAll(/content-desc="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean); + console.log(`\n=== ${label} ===\n text:`, Array.from(new Set(t)).join(' | '), '\n desc:', Array.from(new Set(d)).join(' | ')); + return src; + }; + + const find = () => driver.findElementRaw('-android uiautomator', `new UiSelector().text("${category}")`); + let el = await find(); + for (let i = 0; i < 6 && !el; i++) { await sleep(800); el = await find(); } + for (let i = 0; i < 8 && !el; i++) { await driver.scrollDown(600); await sleep(700); el = await find(); } + console.log(`找到品类 ${category}:`, !!el); + if (!el) return; + await driver.tapElement(el); + await sleep(3000); + + // 过一次 Disclaimers 的 Next + let src = await dump('选品类后'); + if (src.includes('Disclaimer')) { + const next = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (next) { await driver.tapElement(next); await sleep(2500); } + } + await dump('Add 页(滑动前)'); + + // 按继电器配对,设备开始广播 + console.log(`\n>>> 按 ${device} 配对...`); + await enterPairingMode(device).catch((e) => console.log('relay 失败:', e.message)); + await sleep(3000); + + // 向下滑动查找设备入口 + for (let i = 0; i <= 5; i++) { + src = await dump(i === 0 ? '配对后(顶部)' : `下滑#${i}`); + if (src.includes(keyword)) { console.log(`\n>>> 找到设备 "${keyword}"`); break; } + await driver.scrollDown(500); + await sleep(1500); + } + } finally { + await driver.destroySession(); + } +} +main().catch((e) => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/debug-add-shot.ts b/scripts/debug-add-shot.ts new file mode 100644 index 0000000..b51d193 --- /dev/null +++ b/scripts/debug-add-shot.ts @@ -0,0 +1,44 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +import * as fs from 'fs'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; +import { sleep } from '../utils/common/element.helper'; + +// 导航到 Add 页并逐页截图(过 Disclaimers),便于人工确认实际 UI +async function main() { + const category = process.argv[2] || 'Meter'; + const steps = parseInt(process.argv[3] || '3', 10); // 截多少页(每页点一次 Next) + const driver = createDriver(); + await driver.createSession(); + const outDir = path.resolve(__dirname, '../reports'); + fs.mkdirSync(outDir, { recursive: true }); + try { + await navigateToAddPage(driver, 'Device'); + await sleep(2500); + const find = () => driver.findElementRaw('-android uiautomator', `new UiSelector().text("${category}")`); + let el = await find(); + for (let i = 0; i < 6 && !el; i++) { await sleep(800); el = await find(); } + for (let i = 0; i < 8 && !el; i++) { await driver.scrollDown(600); await sleep(700); el = await find(); } + if (!el) { console.log('未找到品类', category); return; } + await driver.tapElement(el); + await sleep(3000); + + for (let i = 1; i <= steps; i++) { + const b64 = await driver.screenshot(); + const f = path.join(outDir, `add_${category}_step${i}.png`); + fs.writeFileSync(f, Buffer.from(b64, 'base64')); + const src = await driver.getSource(); + const t = Array.from(src.matchAll(/text="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean); + console.log(`step${i} 截图 ${f} | text: ${Array.from(new Set(t)).join(' | ')}`); + const next = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (!next) { console.log(' 无 Next,停止'); break; } + await driver.tapElement(next); + await sleep(2500); + } + } finally { + await driver.destroySession(); + } +} +main().catch((e) => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/debug-bot-scan.ts b/scripts/debug-bot-scan.ts new file mode 100644 index 0000000..3458c9e --- /dev/null +++ b/scripts/debug-bot-scan.ts @@ -0,0 +1,34 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; +import { selectDeviceCategory } from '../utils/common/device-add.helper'; +import { sleep } from '../utils/common/element.helper'; + +// 进 Bot 添加扫描页,dump 扫描列表里多个 Bot 的真实条目(看是否带唯一后缀/MAC) +async function main() { + const category = process.argv[2] || 'Bot'; + const driver = createDriver(); + await driver.createSession(); + try { + await navigateToAddPage(driver, 'Device'); + await sleep(2500); + const ok = await selectDeviceCategory(driver, category); + console.log(`选品类 ${category}:`, ok); + // 扫描页多次采样,dump 所有含 Bot 的条目(text + content-desc) + for (let i = 1; i <= 4; i++) { + await sleep(2500); + const src = await driver.getSource(); + const t = Array.from(src.matchAll(/text="([^"]{1,40})"/g)).map((m) => m[1]).filter(Boolean); + const d = Array.from(src.matchAll(/content-desc="([^"]{1,40})"/g)).map((m) => m[1]).filter(Boolean); + const all = Array.from(new Set([...t, ...d])); + const bots = all.filter((x) => /bot|[0-9A-Fa-f]{2}$|:|MAC/i.test(x)); + console.log(`\n采样#${i} 全部:`, all.join(' | ')); + console.log(`采样#${i} 疑似设备/ID:`, bots.join(' | ')); + } + } finally { + await driver.destroySession(); + } +} +main().catch((e) => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/debug-curtain-add.ts b/scripts/debug-curtain-add.ts new file mode 100644 index 0000000..19f7194 --- /dev/null +++ b/scripts/debug-curtain-add.ts @@ -0,0 +1,76 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; +import { selectDeviceCategory } from '../utils/common/device-add.helper'; +import { enterPairingMode } from '../utils/common/relay.helper'; +import { sleep } from '../utils/common/element.helper'; + +// 走 curtain 添加,过引导按继电器配对,然后逐页 dump,定位"行程校验"页及其交互 +async function main() { + const category = process.argv[2] || 'Curtain'; + const relayDevice = process.argv[3] || 'curtain'; + const driver = createDriver(); + await driver.createSession(); + try { + const dump = async (label: string) => { + const src = await driver.getSource(); + const t = Array.from(src.matchAll(/text="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean); + const d = Array.from(src.matchAll(/content-desc="([^"]{1,50})"/g)).map((m) => m[1]).filter(Boolean); + console.log(`\n=== ${label} ===\n text:`, Array.from(new Set(t)).join(' | '), '\n desc:', Array.from(new Set(d)).join(' | ')); + return src; + }; + + await navigateToAddPage(driver, 'Device'); + await sleep(2500); + const ok = await selectDeviceCategory(driver, category); + console.log(`选品类 ${category}:`, ok); + if (!ok) return; + + // 过 Disclaimers + let src = await dump('选品类后'); + if (src.includes('Disclaimer')) { + const next = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (next) { await driver.tapElement(next); await sleep(2500); } + } + await dump('引导页(按键前)'); + + // 继电器按开关键 2s 进配对 + console.log(`\n>>> 继电器配对 ${relayDevice}...`); + await enterPairingMode(relayDevice).catch((e) => console.log('relay 失败:', e.message)); + await sleep(2000); + // 点 Next 连接 + const n = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")'); + if (n) { await driver.tapElement(n); console.log('点了 Next 连接'); } + + // 逐页观察(含连接、选模式、行程校验、命名…),关键字命中即重点标注 + for (let i = 1; i <= 20; i++) { + await sleep(3000); + src = await dump(`连接后 第${i}页`); + if (/Calibrat|行程|Stroke|stroke|校准|校正|Set.*Position|Travel|Adjust/i.test(src)) { + console.log(`\n>>> 第${i}页 疑似【行程校验】,停下详查`); + break; + } + // 选模式页:选"单边开合"再 Confirm + if (src.includes('Select Mode') || src.includes('Open from one side')) { + const one = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Open from one side")') + || await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Open from one side")'); + if (one) { await driver.tapElement(one); await sleep(1000); console.log(' → 选 Open from one side'); } + const conf = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Confirm")'); + if (conf) { await driver.tapElement(conf); console.log(' → Confirm'); } + continue; + } + const nx = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")') + || await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Got it")') + || await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Start")') + || await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Continue")') + || await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Confirm")'); + if (nx) { await driver.tapElement(nx); console.log(' → 点击推进'); } + else console.log(' (无按钮,等待…可能在连接中)'); + } + } finally { + await driver.destroySession(); + } +} +main().catch((e) => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/debug-curtain3-cal.ts b/scripts/debug-curtain3-cal.ts new file mode 100644 index 0000000..484be8a --- /dev/null +++ b/scripts/debug-curtain3-cal.ts @@ -0,0 +1,43 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; +import { selectDeviceCategory } from '../utils/common/device-add.helper'; +import { enterPairingMode } from '../utils/common/relay.helper'; +import { sleep, waitForSource } from '../utils/common/element.helper'; + +// 走 curtain3 全程,到 Auto-Calibrate Close 后逐页 dump,看真实收尾流程 +async function main() { + const d = createDriver(); + await d.createSession(); + try { + const dump = async (l: string) => { const s = await d.getSource(); const t = Array.from(s.matchAll(/text="([^"]{1,60})"/g)).map(m => m[1]).filter(Boolean); console.log(`\n=== ${l} ===\n`, Array.from(new Set(t)).join(' | ')); return s; }; + const tapText = async (txt: string) => { const e = await d.findElementRaw('-android uiautomator', `new UiSelector().text("${txt}")`); if (e) { await d.tapElement(e); console.log(' 点', txt); return true; } return false; }; + const tapDesc = async (desc: string) => { const e = await d.findElementRaw('-android uiautomator', `new UiSelector().descriptionContains("${desc}")`); if (e) { await d.tapElement(e); console.log(' 勾', desc); return true; } return false; }; + + await navigateToAddPage(d, 'Device'); await sleep(2500); + await selectDeviceCategory(d, 'Curtain'); + let s = await d.getSource(); + if (s.includes('Disclaimer')) { await tapText('Next'); await sleep(2500); } + await enterPairingMode('curtain3').catch(() => {}); + await sleep(1500); await tapText('Next'); + + await waitForSource(d, 'Select Mode', 60000).catch(() => {}); + if (!(await tapDesc('Open from one side'))) await tapText('Open from one side'); + await sleep(1000); await tapText('Confirm'); + await waitForSource(d, 'Install', 30000).catch(() => {}); + await tapText('Install'); + await waitForSource(d, 'rail type', 15000).catch(() => {}); await tapText('Skip'); + await waitForSource(d, 'Custom Calibration', 15000).catch(() => {}); await tapText('Custom Calibration'); + await waitForSource(d, 'Confirm Orientation', 15000).catch(() => {}); await tapDesc('facing inwards'); await sleep(800); await tapText('Next'); + await waitForSource(d, 'fully closed', 15000).catch(() => {}); await tapDesc('fully closed'); await sleep(800); await tapText('Start Calibration'); + await waitForSource(d, 'Magnet Detection', 15000).catch(() => {}); await tapText('Skip magnet detection'); await sleep(1500); await tapText('Skip'); + await waitForSource(d, 'Move left', 20000).catch(() => {}); + await tapText('Move left'); console.log('等15s...'); await sleep(15000); + await tapText('Auto-Calibrate Close'); + // ↓↓↓ 重点:Auto-Calibrate Close 之后逐页观察 + for (let i = 1; i <= 12; i++) { await sleep(3000); s = await dump(`Auto-Close 后 第${i}页`); const left = await d.findElementsRaw('id', 'com.theswitchbot.switchbot:id/leftCard'); if (left.length) { console.log(`>>> 回首页 卡片=${left.length}`); break; } } + } finally { await d.destroySession(); } +} +main().catch((e) => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/debug-find-category.ts b/scripts/debug-find-category.ts new file mode 100644 index 0000000..f217368 --- /dev/null +++ b/scripts/debug-find-category.ts @@ -0,0 +1,31 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; +import { sleep } from '../utils/common/element.helper'; + +// 下滑添加页,逐屏找含 "Remote" 的文案,定位标准 Remote 入口的确切文字与位置 +async function main() { + const needle = process.argv[2] || 'Remote'; + const driver = createDriver(); + await driver.createSession(); + try { + await navigateToAddPage(driver, 'Device'); + await sleep(2500); + for (let i = 0; i <= 10; i++) { + const src = await driver.getSource(); + const hits = Array.from(src.matchAll(/text="([^"]*)"/g)).map((m) => m[1]) + .filter((t) => t && t.toLowerCase().includes(needle.toLowerCase())); + console.log(`屏#${i} 命中"${needle}":`, hits.length ? Array.from(new Set(hits)).join(' | ') : '(无)'); + // 命中精确 needle 就停 + const exact = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${needle}")`); + if (exact) { console.log(`>>> 屏#${i} 找到精确 text("${needle}")`); break; } + await driver.scrollDown(700); + await sleep(1200); + } + } finally { + await driver.destroySession(); + } +} +main().catch((e) => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/debug-longpress-delete.ts b/scripts/debug-longpress-delete.ts new file mode 100644 index 0000000..eca2a98 --- /dev/null +++ b/scripts/debug-longpress-delete.ts @@ -0,0 +1,43 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { ensureHomeTab } from '../utils/common/navigation.helper'; +import { sleep } from '../utils/common/element.helper'; + +// 探索:首页房间标签 + 长按设备卡进入删除模式后出现什么 +async function main() { + const cardText = process.argv[2] || 'Meter'; + const driver = createDriver(); + await driver.createSession(); + try { + await ensureHomeTab(driver); + await driver.dismissPopupIfPresent(); + await sleep(1500); + + const dump = async (label: string) => { + const src = await driver.getSource(); + const t = Array.from(src.matchAll(/text="([^"]{1,40})"/g)).map((m) => m[1]).filter(Boolean); + const d = Array.from(src.matchAll(/content-desc="([^"]{1,40})"/g)).map((m) => m[1]).filter(Boolean); + console.log(`\n=== ${label} ===\n text:`, Array.from(new Set(t)).join(' | '), '\n desc:', Array.from(new Set(d)).join(' | ')); + }; + // 房间标签 + const rooms = await driver.findElementsRaw('id', 'com.theswitchbot.switchbot:id/clSelectRoom'); + console.log('房间标签数:', rooms.length); + for (const r of rooms) { const tx = await driver.getElementAttribute(r, 'text').catch(() => ''); if (tx) console.log(' 房间:', tx); } + + await dump('首页(长按前)'); + + // 找设备卡 → 长按其中心 + let card = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${cardText}")`); + if (!card) { console.log(`未找到含"${cardText}"的卡片`); return; } + const rect = await driver.getElementRect(card); + console.log(`长按 "${cardText}" 卡片中心 (${Math.round(rect.x + rect.width / 2)}, ${Math.round(rect.y + rect.height / 2)})`); + await driver.longPress(Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2), 1.2); + await sleep(2500); + await dump('长按后(删除/管理模式?)'); + } finally { + await driver.destroySession(); + } +} +main().catch((e) => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/dump-product-catalog.ts b/scripts/dump-product-catalog.ts new file mode 100644 index 0000000..5d976a3 --- /dev/null +++ b/scripts/dump-product-catalog.ts @@ -0,0 +1,46 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { createDriver } from '../drivers/factory'; +import { navigateToAddPage } from '../utils/common/navigation.helper'; +import { sleep } from '../utils/common/element.helper'; + +// 抓取 App「添加设备」完整产品目录(产品看板),输出全部单品设备名 +async function main() { + const driver = createDriver(); + await driver.createSession(); + try { + await navigateToAddPage(driver, 'Device'); + await sleep(2500); + // 点 Show More 展开全部品类 + const more = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Show More")'); + if (more) { await driver.tapElement(more); await sleep(1500); } + + const all = new Set(); + // 非产品的 UI 文案/区块标题,过滤掉 + const NON_PRODUCT = new Set([ + 'Add Device', 'Scanning for Bluetooth devices nearby...', 'Show More', 'Show less', + 'Add Manually', 'Home Automation', 'Smart Home Appliances', 'Sensor', 'Camera', + 'Lock', 'Light', 'Curtain', 'Hub', 'Robot Vacuum', 'Others', 'Smart Switch', + 'Climate', 'Security', 'Lighting', 'Cleaning', + ]); + for (let i = 0; i < 16; i++) { + const src = await driver.getSource(); + for (const m of src.matchAll(/text="([^"]{2,45})"/g)) { + const t = m[1].trim(); + if (!t) continue; + if (/^\d+$/.test(t)) continue; // 纯数字(角标计数) + if (NON_PRODUCT.has(t)) continue; + all.add(t); + } + await driver.scrollDown(650); + await sleep(900); + } + const list = Array.from(all).sort(); + console.log('=== 产品目录(共 ' + list.length + ' 项,已过滤UI/区块标题)==='); + list.forEach((x, i) => console.log(`${String(i + 1).padStart(3)}. ${x}`)); + } finally { + await driver.destroySession(); + } +} +main().catch((e) => { console.error('ERR:', e.message); process.exit(1); }); diff --git a/scripts/gen-combined.ts b/scripts/gen-combined.ts new file mode 100644 index 0000000..d1c3e53 --- /dev/null +++ b/scripts/gen-combined.ts @@ -0,0 +1,9 @@ +/** + * 从累积的 reports/.results.json 生成一份合并报告(逐用例 + 失败截图 + 结论/问题/解决方案)。 + * 供 soak 全程结束后调用(SOAK_MODE 下 .results.json 跨多次 vitest run 累积)。 + * npx ts-node scripts/gen-combined.ts <报告名前缀> + */ +import { TestReporter } from '../utils/test-reporter'; +const name = process.argv[2] || 'Combined'; +const out = TestReporter.generateCombinedReport(name); +console.log('合并报告:', out || '(无结果)'); diff --git a/scripts/gen-must-test-manifest.ts b/scripts/gen-must-test-manifest.ts index b16761e..fe6a834 100644 --- a/scripts/gen-must-test-manifest.ts +++ b/scripts/gen-must-test-manifest.ts @@ -42,6 +42,13 @@ const CATEGORY_MODULES = new Set([ 'Yb6hgZ9T', // 扫地机类 ]); +// 平台级模块(账号/房间/消息/场景/安装等)—— 同属 P0 必测,非设备品类 +const PLATFORM_MODULES = new Set([ + 'Czs8zN3v', // 我的(账号/房间/feedback) + 'Eeme1qVk', // 其他功能(消息中心/插件热更/覆盖安装/场景操作) + 'PJpV3Qz2', // 平台功能(第三方操作) +]); + // 关键字 -> 品类(= 测试目录名, snake_case)。按最长命中优先匹配。 const KEYWORD_TO_CATEGORY: [string, string][] = [ ['relay switch', 'plug'], ['garage', 'plug'], ['plug mini', 'plug'], ['plugmini', 'plug'], ['eu plug', 'plug'], ['plug', 'plug'], @@ -101,7 +108,7 @@ function shortDesc(s: string): string { } interface AddRow { - kind: 'add' | 'feature'; + kind: 'add' | 'feature' | 'platform'; ones: number; name: string; cat: string; @@ -166,6 +173,22 @@ function main() { }); } + // 1b) platform: 平台模块内、在计划内的 case(P0,非设备) + for (const c of libCases) { + if (!PLATFORM_MODULES.has(c.module_uuid)) continue; + if (!planNums.has(c.number)) continue; + rows.push({ + kind: 'platform', + ones: c.number, + name: c.name, + cat: 'platform', + device: '', + file: '', // 多为新建脚本;场景操作可指向 tests/automation + testName: `[P0][ONES:${c.number}] ${c.name}`, + status: 'todo', + }); + } + // 2) ctrl: 两条协议超级用例的每个 step for (const { number, proto } of CTRL_CASES) { const found = runOnes(`testcase case search --key ${number}`).cases || []; @@ -216,9 +239,11 @@ function argVal(argv: string[], flag: string): string | undefined { function printSummary(rows: Row[], na: string[], plan: string) { const by = (k: string) => rows.filter((r) => r.kind === k); const add = by('add'), feat = by('feature'), ctrl = by('ctrl') as CtrlRow[]; + const platform = by('platform'); console.log(`\n=== 必测项覆盖摘要 (plan ${plan}) ===`); console.log(`add(添加) : ${add.length}`); console.log(`feature(功能): ${feat.length}`); + console.log(`platform(平台): ${platform.length}`); console.log(`ctrl(控制step): ${ctrl.length} (ble ${ctrl.filter((r) => r.proto === 'ble').length} / wifi ${ctrl.filter((r) => r.proto === 'wifi').length})`); const catCount: Record = {}; for (const r of rows) catCount[r.cat || '∅'] = (catCount[r.cat || '∅'] || 0) + 1; @@ -234,7 +259,7 @@ function renderManifest(rows: Row[], plan: string): string { export type MustTestItem = | { - kind: 'add' | 'feature'; + kind: 'add' | 'feature' | 'platform'; ones: number; name: string; cat: string; diff --git a/scripts/overnight-soak-ios.sh b/scripts/overnight-soak-ios.sh new file mode 100644 index 0000000..d09531b --- /dev/null +++ b/scripts/overnight-soak-ios.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# iOS 夜间添加稳定性 soak:循环【清空设备 → 逐个添加】到次日 09:00。 +# 关键:iOS 走 WDA(8100),WDA 易掉 → 每轮检查并自动重起;每个设备添加前 terminate/launch 把 app 复位到首页 +# (iOS navigateToAddPage 无回首页恢复)。计数沿用 SKIP 护栏(SKIP 不计 PASS)。 +# nohup caffeinate -dimsu bash scripts/overnight-soak-ios.sh > reports/soak-ios.out 2>&1 & +set -u +cd "$(dirname "$0")/.." +export PLATFORM=ios +export SOAK_MODE=1 +UDID=00008110-001A34303AE9801E +BUNDLE=com.wohand.wohand + +DEADLINE=$(date -v+1d -v9H -v0M -v0S +%s 2>/dev/null) || DEADLINE=$(($(date +%s) + 50400)) +TS=$(date +%Y%m%d-%H%M%S) +mkdir -p reports +LOG="reports/soak-ios-$TS.log" +DETAIL="reports/soak-ios-$TS.detail.log" + +DEVICES=( + "meter|tests/meter/meter_connect.test.ts|" + "remote|tests/remote/remote_connect.test.ts|" + "hub|tests/hub/hub_connect.test.ts|export HUB_DEVICE='HubMini 9I'" +) + +PASS=(); FAIL=(); SKIP=() +for i in "${!DEVICES[@]}"; do PASS[$i]=0; FAIL[$i]=0; SKIP[$i]=0; done +log(){ echo "[$(date '+%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; } + +wda_sid(){ curl -s --max-time 5 http://localhost:8100/status 2>/dev/null | grep -oE '[0-9A-F]{8}-[0-9A-F-]{27,}' | head -1; } +wda_ready(){ curl -s --max-time 5 http://localhost:8100/status 2>/dev/null | grep -q '"ready" : true'; } +ensure_wda(){ + if wda_ready; then return 0; fi + log " WDA 不在,重起 WDA..." + pkill -f iproxy 2>/dev/null; sleep 1 + curl -s --max-time 280 -X POST http://127.0.0.1:4723/session -H 'Content-Type: application/json' \ + -d "{\"capabilities\":{\"alwaysMatch\":{\"platformName\":\"iOS\",\"appium:automationName\":\"XCUITest\",\"appium:udid\":\"$UDID\",\"appium:platformVersion\":\"26.5\",\"appium:deviceName\":\"iPhone\",\"appium:bundleId\":\"$BUNDLE\",\"appium:wdaLocalPort\":8100,\"appium:noReset\":true,\"appium:newCommandTimeout\":3600}}}" >>"$DETAIL" 2>&1 + sleep 3 + wda_ready && log " WDA 已就绪" || log " WDA 重起失败(本轮可能全失败)" +} +app_home(){ local sid; sid=$(wda_sid); [ -z "$sid" ] && return 0 + curl -s --max-time 15 -X POST "http://localhost:8100/session/$sid/wda/apps/terminate" -H 'Content-Type: application/json' -d "{\"bundleId\":\"$BUNDLE\"}" >/dev/null 2>&1 + sleep 1 + curl -s --max-time 15 -X POST "http://localhost:8100/session/$sid/wda/apps/launch" -H 'Content-Type: application/json' -d "{\"bundleId\":\"$BUNDLE\"}" >/dev/null 2>&1 + sleep 2 +} + +names=""; for spec in "${DEVICES[@]}"; do names+="${spec%%|*} "; done +log "===== iOS SOAK 开始,截止 $(date -r $DEADLINE '+%m-%d %H:%M' 2>/dev/null) ; 设备: $names=====" +rm -f reports/.results.json +ensure_wda + +cycle=0 +while [ "$(date +%s)" -lt "$DEADLINE" ]; do + cycle=$((cycle+1)) + log "----- Cycle $cycle -----" + ensure_wda + app_home + if RESET_DEVICES=1 npx ts-node scripts/reset-devices.ts >>"$DETAIL" 2>&1; then log " reset 完成"; else log " reset 异常(继续)"; fi + + for i in "${!DEVICES[@]}"; do + spec="${DEVICES[$i]}"; name="${spec%%|*}"; rest="${spec#*|}"; file="${rest%%|*}"; envs="${rest#*|}" + echo "[$(date '+%H:%M:%S')] >>> Cycle $cycle $name" >>"$DETAIL" + ensure_wda; app_home # 每个设备添加前复位到首页(iOS 导航无回首页恢复) + runlog=$(mktemp) + ( eval "$envs"; npx vitest run "$file" ) >"$runlog" 2>&1; rc=$? + cat "$runlog" >>"$DETAIL" + if grep -q "\[SKIP\]" "$runlog"; then + SKIP[$i]=$(( SKIP[$i] + 1 )); log " $name SKIP(设备已存在 → 不计通过)" + elif [ $rc -eq 0 ] && grep -q "\[PASS\]" "$runlog"; then + PASS[$i]=$(( PASS[$i] + 1 )); log " $name PASS" + else + FAIL[$i]=$(( FAIL[$i] + 1 )); log " $name FAIL (见 $DETAIL)" + fi + rm -f "$runlog" + done + + tally=""; for i in "${!DEVICES[@]}"; do n="${DEVICES[$i]%%|*}"; tot=$(( PASS[$i] + FAIL[$i] + SKIP[$i] )); tally+="$n ${PASS[$i]}/${tot}(skip${SKIP[$i]}) "; done + log " 累计通过率(pass/total,skip另计): $tally" +done + +log "===== iOS SOAK 结束,共 $cycle 轮 =====" +final=""; for i in "${!DEVICES[@]}"; do n="${DEVICES[$i]%%|*}"; tot=$(( PASS[$i] + FAIL[$i] + SKIP[$i] )); final+="$n=${PASS[$i]}/${tot}(skip${SKIP[$i]}) "; done +log "最终: $final" +log "生成汇总报告..." +npx ts-node scripts/soak-report.ts "ios-$TS" >>"$DETAIL" 2>&1 && log "时间线汇总: reports/soak-report-ios-$TS.*" || log "时间线汇总生成失败" +npx ts-node scripts/gen-combined.ts "SoakIOS_$TS" >>"$DETAIL" 2>&1 && log "逐用例报告: reports/SoakIOS_${TS}_*.html" || log "逐用例报告生成失败" diff --git a/scripts/overnight-soak.sh b/scripts/overnight-soak.sh new file mode 100644 index 0000000..37b8540 --- /dev/null +++ b/scripts/overnight-soak.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# 夜间添加稳定性 soak:循环【清空所有设备 → 逐个添加】直到次日 09:00,记录每设备 通过/失败 计数。 +# 用法(后台不挂断运行): +# nohup bash scripts/overnight-soak.sh > reports/soak.out 2>&1 & +# 设备清单见 DEVICES;每项 "名称|测试文件|额外ENV(eval形式export语句,可含空格值)"。 +# 兼容 macOS 自带 bash 3.2(用索引数组,不用关联数组)。 +set -u +cd "$(dirname "$0")/.." +export PLATFORM=android +export SOAK_MODE=1 # 让各次 vitest run 累积结果到 reports/.results.json,全程结束统一出一份逐用例+截图报告 + +# 截止时间 = 次日 09:00(macOS date 语法) +DEADLINE=$(date -v+1d -v9H -v0M -v0S +%s 2>/dev/null) || DEADLINE=$(($(date +%s) + 50400)) +TS=$(date +%Y%m%d-%H%M%S) +mkdir -p reports +LOG="reports/soak-$TS.log" +DETAIL="reports/soak-$TS.detail.log" + +DEVICES=( + "meter|tests/meter/meter_connect.test.ts|" + "outdoor_meter|tests/meter/outdoor_meter_connect.test.ts|" + "remote|tests/remote/remote_connect.test.ts|" + "curtain3|tests/curtain/curtain_connect.test.ts|export CURTAIN_DEVICE='Curtain3 2B'; export CURTAIN_CALIBRATE=1" +) + +# 索引数组(bash 3.2 兼容) +PASS=(); FAIL=(); SKIP=() +for i in "${!DEVICES[@]}"; do PASS[$i]=0; FAIL[$i]=0; SKIP[$i]=0; done + +log(){ echo "[$(date '+%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; } + +names=""; for spec in "${DEVICES[@]}"; do names+="${spec%%|*} "; done +log "===== SOAK 开始,截止 $(date -r $DEADLINE '+%m-%d %H:%M' 2>/dev/null) ; 设备: $names=====" +rm -f reports/.results.json # 全程开始前清一次,之后累积所有轮次的逐用例结果 + +cycle=0 +while [ "$(date +%s)" -lt "$DEADLINE" ]; do + cycle=$((cycle+1)) + log "----- Cycle $cycle -----" + + if RESET_DEVICES=1 npx ts-node scripts/reset-devices.ts >>"$DETAIL" 2>&1; then + log " reset 完成" + else + log " reset 异常(继续)" + fi + + for i in "${!DEVICES[@]}"; do + spec="${DEVICES[$i]}" + name="${spec%%|*}"; rest="${spec#*|}"; file="${rest%%|*}"; envs="${rest#*|}" + echo "[$(date '+%H:%M:%S')] >>> Cycle $cycle $name" >>"$DETAIL" + runlog=$(mktemp) + ( eval "$envs"; npx vitest run "$file" ) >"$runlog" 2>&1; rc=$? + cat "$runlog" >>"$DETAIL" + # 按实际结果计数:SKIP(设备已存在=reset 未清干净)单列,绝不计入 PASS,以免通过率被灌水 + if grep -q "\[SKIP\]" "$runlog"; then + SKIP[$i]=$(( SKIP[$i] + 1 )); log " $name SKIP(设备已存在,reset 未清干净 → 不计通过)" + elif [ $rc -eq 0 ] && grep -q "\[PASS\]" "$runlog"; then + PASS[$i]=$(( PASS[$i] + 1 )); log " $name PASS" + else + FAIL[$i]=$(( FAIL[$i] + 1 )); log " $name FAIL (见 $DETAIL)" + fi + rm -f "$runlog" + done + + tally="" + for i in "${!DEVICES[@]}"; do n="${DEVICES[$i]%%|*}"; tot=$(( PASS[$i] + FAIL[$i] + SKIP[$i] )); tally+="$n ${PASS[$i]}/${tot}(skip${SKIP[$i]}) "; done + log " 累计通过率(pass/total,skip另计): $tally" +done + +log "===== SOAK 结束,共 $cycle 轮 =====" +final=""; for i in "${!DEVICES[@]}"; do n="${DEVICES[$i]%%|*}"; tot=$(( PASS[$i] + FAIL[$i] + SKIP[$i] )); final+="$n=${PASS[$i]}/${tot}(skip${SKIP[$i]}) "; done +log "最终: $final" + +# 生成单份汇总报告(结论/问题/解决方案) +log "生成汇总报告..." +npx ts-node scripts/soak-report.ts "$TS" >>"$DETAIL" 2>&1 && log "时间线汇总: reports/soak-report-$TS.md / .html" || log "时间线汇总生成失败(见 $DETAIL)" +# 逐用例 + 失败截图的整体报告(参照 combined 报告格式) +npx ts-node scripts/gen-combined.ts "Soak_$TS" >>"$DETAIL" 2>&1 && log "逐用例报告: reports/Soak_${TS}_*.html" || log "逐用例报告生成失败(见 $DETAIL)" diff --git a/scripts/relay-press-report.ts b/scripts/relay-press-report.ts new file mode 100644 index 0000000..f5eadbe --- /dev/null +++ b/scripts/relay-press-report.ts @@ -0,0 +1,49 @@ +/** + * 继电器开发板按钮点击次数报告 —— 统计每块板、每个通道(=设备按键)的累计点击, + * 对照继电器约 20 万次寿命,显示占比/剩余/告警。多块板按串口序列号自动分别统计。 + * npx ts-node scripts/relay-press-report.ts + * 数据来源:test-plan/relay-press-counts.json(由 SerialController 每次通道吸合时累加)。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { RELAY_MAP } from '../config/relay.config'; + +const FILE = path.resolve(__dirname, '../test-plan/relay-press-counts.json'); +const LIFE = 200000; + +// 反查:通道号 → "设备.按键"(取 android 通道;两端不同时也显示 ios) +const chLabel: Record = {}; +for (const [dev, cfg] of Object.entries(RELAY_MAP)) { + for (const [btn, spec] of Object.entries(cfg.buttons)) { + const chs = typeof spec === 'number' ? [spec] : [spec.android, spec.ios].filter((x): x is number => x != null); + for (const ch of chs) chLabel[ch] = `${dev}.${btn}`; + } +} + +const data: Record> = fs.existsSync(FILE) + ? JSON.parse(fs.readFileSync(FILE, 'utf-8')) : {}; + +if (!Object.keys(data).length) { + console.log('(暂无点击计数;跑过添加/继电器操作后才会有。文件:' + FILE + ')'); + process.exit(0); +} + +for (const [board, chs] of Object.entries(data)) { + console.log(`\n=== 开发板 ${board} ===`); + console.log('通道'.padEnd(6) + '设备.按键'.padEnd(22) + '点击次数'.padEnd(12) + '寿命占比'.padEnd(10) + '剩余'); + const entries = Object.entries(chs).map(([ch, c]) => [Number(ch), c] as [number, number]).sort((a, b) => b[1] - a[1]); + let total = 0; + for (const [ch, cnt] of entries) { + total += cnt; + const pct = (cnt / LIFE * 100).toFixed(2) + '%'; + const warn = cnt >= LIFE * 0.9 ? ' ⚠️已超90%寿命' : cnt >= LIFE * 0.8 ? ' ⚠接近寿命' : ''; + console.log( + `ch${ch}`.padEnd(6) + + (chLabel[ch] || '-').padEnd(22) + + String(cnt).padEnd(12) + + pct.padEnd(10) + + String(LIFE - cnt) + warn + ); + } + console.log(`板内累计点击: ${total}`); +} diff --git a/scripts/relay.ts b/scripts/relay.ts new file mode 100644 index 0000000..ff3e2f9 --- /dev/null +++ b/scripts/relay.ts @@ -0,0 +1,106 @@ +/** + * 继电器命令行调试工具 —— 参数化触发按键/配对/原始命令,方便接线调试。 + * + * 用法(端口/波特率走 .env 的 SERIAL_PORT/SERIAL_BAUD): + * npx ts-node scripts/relay.ts probe # 查版本(只读握手) + * npx ts-node scripts/relay.ts status # 读 64 路状态 + * npx ts-node scripts/relay.ts pair remote # 进配对(按 config 默认) + * npx ts-node scripts/relay.ts pair meter --hold 2500 # 进配对,覆盖时长 + * npx ts-node scripts/relay.ts press remote convex --hold 800 # 点按命名键 + * npx ts-node scripts/relay.ts pulse 34,35 --hold 3000 # 同时脉冲指定通道 + * npx ts-node scripts/relay.ts on 34 / off 34 # 单路通/断 + * npx ts-node scripts/relay.ts raw 55C8210155 # 发原始 16 进制帧 + */ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + +import { SerialController } from '../utils/common/serial_controller'; +import { triggerRelayAction, pressButton } from '../utils/common/relay.helper'; + +function flag(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +/** 取位置参数(剔除 --flag 及其紧随的值)。 */ +function positionals(): string[] { + const argv = process.argv.slice(2); + const out: string[] = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + i++; // 跳过该 flag 的值 + continue; + } + out.push(argv[i]); + } + return out; +} + +async function main() { + const [cmd, a, b] = positionals(); + const hold = flag('hold') ? parseInt(flag('hold') as string, 10) : undefined; + + switch (cmd) { + case 'probe': { + const c = new SerialController(); + await c.open(); + console.log(await c.queryVersion()); + await c.close(); + break; + } + case 'status': { + const c = new SerialController(); + await c.open(); + console.log('status:', (await c.readStatus()).toString('hex').toUpperCase()); + await c.close(); + break; + } + case 'pair': { + if (!a) throw new Error('用法: pair [--hold ms]'); + await triggerRelayAction(a, 'pairing', hold ? { holdMs: hold } : {}); + break; + } + case 'press': { + if (!a || !b) throw new Error('用法: press