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, pressButton, setupResilientHooks } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); // Keypad Vision Pro 闭环功能(feature [ONES:15973]):绑定门锁 → 加永久密码 → 上锁 → 物理键盘输密码解锁 → 校验。 // 功能页菜单:Pair to Lock / Passcode / Face Entry / Fingerprint / NFC Card。 // 物理键盘:数字键 1-6=ch51-56、确定=ch34(relay 'keypad' 的 k1..k6 / confirm)。 const KEYPAD = process.env.KEYPAD_CARD || 'Keypad Vision'; const LOCK = process.env.KEYPAD_LOCK || 'Lock 6D'; // 绑定的门锁(Lock Pro DE 常 Too far) // KP_SKIP_PHYSICAL=1:只调功能页 app-UI(绑定读取/加密码/删密码/解绑),**跳过物理上锁/解锁**(ch35/ch51-56/ch34)。 // 用于继电器被占用(如 iOS 添加在跑、抢同一串口)时,安全调功能页而不下发继电器指令。 const SKIP_PHYSICAL = process.env.KP_SKIP_PHYSICAL === '1'; const PASSCODE = process.env.KEYPAD_PASSCODE || '123456'; const ANCHOR = '[P0][ONES:15973]'; async function tapText(d: DeviceDriver, t: string): Promise { const el = await d.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`).catch(() => null); if (el) { await d.tapElement(el); return true; } return false; } async function srcHas(d: DeviceDriver, t: string): Promise { return (await d.getSource()).includes(t); } const texts = async (d: DeviceDriver) => { const src = await d.getSource(); const re = d.platform === 'ios' ? /(?:name|label)="([^"]+)"/g : /text="([^"]+)"/g; // iOS 用 name/label(无 text=),否则日志读空 return [...new Set(Array.from(src.matchAll(re)).map((m) => m[1]).filter((t) => t && t.length < 30))].slice(0, 20); }; describe('Keypad Vision Pro - 绑锁+密码+物理解锁闭环', () => { let driver: DeviceDriver; let reporter: TestReporter; setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); await driver.createSession(); reporter = new TestReporter('KeypadVision_LockPasscode', driver.platform.toUpperCase()); }); afterAll(async () => { reporter.generate(); await driver.destroySession(); }); async function enterKeypad(): Promise { await driver.dismissPopupIfPresent(); await driver.goBackToHomepage(); await sleep(1000); const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 })); let entered = false; for (let i = 0; i < 12 && !entered; i++) { const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${KEYPAD}")`).catch(() => null); if (el) { const r = await driver.getElementRect(el).catch(() => null); if (driver.platform === 'ios') { // iOS:点卡片(名 StaticText)中心进功能页;须在可视区(原 Android swipe 540/1500 坐标 iOS 不通) if (r && r.width > 0 && r.y > winH * 0.08 && r.y + r.height < winH * 0.92) { await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); entered = true; break; } } else if (r) { await driver.tap(r.x + 100, r.y - 20); entered = true; break; // Android:卡名右上(开关钮上方)进功能页 } } await driver.scrollDown(driver.platform === 'ios' ? 500 : 400); await sleep(700); } if (!entered) throw new Error(`首页找不到 ${KEYPAD} 卡片`); await sleep(5000); await driver.dismissPopupIfPresent(); await sleep(1000); // 关"Do not show again"引导弹框 if (await srcHas(driver, 'Do not show again')) { const cb = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Do not show again")').catch(() => null); if (cb) await driver.tapElement(cb).catch(() => {}); for (const t of ['OK', 'Got it', 'Confirm', 'I Know']) { if (await tapText(driver, t)) { await sleep(1500); break; } } } } it(`${ANCHOR} 绑锁+加密码+上锁+物理输密码解锁`, { timeout: 240000 }, async () => { const start = Date.now(); try { // 1) 进功能页 + 绑定门锁(未绑才绑):选锁行 → 等 → 点底部 Pair → 轮询配对完成 await enterKeypad(); console.log('[KP] 功能页:', JSON.stringify(await texts(driver))); // 绑定门锁(未绑才绑),最多 2 次;**绑定失败会一直停在"绑定锁"弹框/Not paired**(昨晚根因)→ 必须强校验。 const isPaired = async () => /Paired to/.test(await driver.getSource()); async function bindOnce(): Promise { if (!(await tapText(driver, 'Pair to Lock'))) return; await sleep(3000); console.log('[KP] 选锁页:', JSON.stringify(await texts(driver))); await tapText(driver, LOCK); await sleep(2500); // 选中锁(行右单选) // 底部确认按钮:Android="Pair";**iOS="Next"**(选锁页确认键不同)→ 两个都试。 const pb = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Pair")').catch(() => null) || await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")').catch(() => null); if (pb) { await driver.tapElement(pb); } // 点确认进配对 // Connecting/Saving(慢)→ Test Your Keypad(点 Skip)→ Paired to。给足窗口(~90s)。 for (let i = 0; i < 30; i++) { await sleep(3000); if (await srcHas(driver, 'Test Your Keypad') || await srcHas(driver, 'Test Code')) { for (const t of ['Skip', '跳过', 'Skip Test', 'Skip test']) { if (await tapText(driver, t)) { console.log('[KP] Test页点Skip跳过'); await sleep(3000); break; } } } await driver.dismissPopupIfPresent(); if (await isPaired()) return; } } for (let attempt = 1; attempt <= 2 && !(await isPaired()); attempt++) { if (!(await srcHas(driver, 'Not paired')) && !(await srcHas(driver, 'Pair to Lock'))) break; // 非未绑态 console.log(`[KP] 绑定 ${LOCK}(第 ${attempt}/2 次)...`); await bindOnce(); if (!(await isPaired())) { console.log('[KP] 本次未绑成功,重进功能页清绑定弹框残留再试'); await enterKeypad(); } } // 强校验:绑定必须成功(出现 "Paired to"),否则清晰报"绑定失败"(而非下游误报"无Passcode图标")。 if (!(await isPaired())) throw new Error(`绑定 Lock 失败(一直提示绑定锁弹框;目标 ${LOCK} 可能 Too far/离线)`); // 读**实际绑定的锁名**("Paired to XXX")用于后续校验,不写死 Lock 6D。 let boundLock = LOCK; { const m = (await driver.getSource()).match(/Paired to ([^"<]+)/); if (m) { boundLock = m[1].trim(); console.log(`[KP] 实际绑定锁: ${boundLock}`); } } // 2) 加永久密码(实测流程):Passcode tab(横排图标,点元素rect) → 底部无文案Add(H*0.92) → Permanent → 输码 → Next → Set Name → Save → Done // 此时已在 keypad 功能页(step1 之后,不回首页);轮询等 Passcode 图标渲染。 await sleep(2000); const { width: W, height: H } = await driver.getWindowSize(); let pcTile: string | null = null; for (let i = 0; i < 8 && !pcTile; i++) { pcTile = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Passcode")').catch(() => null); if (pcTile) break; await driver.dismissPopupIfPresent(); // 关"绑锁引导/提示"弹框(昨晚根因:进功能页弹框提示帮你绑锁,盖住 Passcode 图标 → 报"无Passcode图标") for (const t of ['Got it', 'OK', 'I Know', '知道了', 'Skip', 'Not Now', 'Later', 'Done']) { if (await tapText(driver, t)) { await sleep(800); break; } } if (i === 4) { console.log('[KP] Passcode 仍未现,重进功能页清残留'); await enterKeypad(); } // 中途重进清残留态/弹框 await sleep(1200); } if (!pcTile) throw new Error('功能页无 Passcode 图标(绑锁未完成或引导/提示弹框未关闭)'); { const r = await driver.getElementRect(pcTile); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); } await sleep(2000); // 底部无文案 Add 按钮(实测 1080x2400 ≈ 540,2211 = W/2, H*0.92) await driver.tap(Math.round(W * 0.5), Math.round(H * 0.92)); await sleep(2500); console.log('[KP] 点底部Add后:', JSON.stringify(await texts(driver))); if (!(await tapText(driver, 'Permanent'))) throw new Error('未到 Add Passcode 类型页(无 Permanent)'); await sleep(2500); const eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'); if (!eds.length) throw new Error('Set Password 页无输入框'); await driver.typeText(eds[0], PASSCODE); await sleep(1000); // 收键盘:Android goBack;**iOS 绝不能 goBack(会退出 Set Password 页)**,点键盘 Done/Return。 if (driver.platform === 'android') { try { await driver.goBack(); } catch { /* 收键盘 */ } } else { for (const k of ['Done', 'Return', 'Go', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } } await tapText(driver, 'Next'); await sleep(2500); // Set Password → Set Name await tapText(driver, 'Save'); await sleep(3500); // Set Name → 保存 await driver.dismissPopupIfPresent(); const added = await srcHas(driver, 'Added successfully') || await srcHas(driver, 'Copy passcode'); await tapText(driver, 'Done'); await sleep(2000); // Added successfully → Done console.log(`[KP] 加密码完成=${added}:`, JSON.stringify(await texts(driver))); if (SKIP_PHYSICAL) { // 功能页调试模式:不下发继电器(避免与 iOS 添加抢串口)→ 跳过物理上锁/解锁/解锁校验。加密码成功即视为功能页 app-UI 通过。 console.log(`[KP] KP_SKIP_PHYSICAL=1 → 跳过物理上锁/解锁;boundLock=${boundLock},加密码=${added}`); expect(added).toBe(true); } else { // 3) 上锁(回首页找 Lock 卡片,确保处于 Locked)——用物理上锁键 ch35 兜底 await driver.goBackToHomepage(); await sleep(1500); // 物理上锁键(ch35) await pressButton('keypad', 'unlock', 400).catch((e) => console.log('ch35 上锁键失败:', e.message)); // 'unlock' 按键=ch35=上锁 await sleep(4000); // 4) 物理键盘输密码 123456 + 确定(ch34) const keys = ['k1', 'k2', 'k3', 'k4', 'k5', 'k6']; for (const k of keys) { await pressButton('keypad', k, 400).catch((e) => console.log(`${k}失败:`, e.message)); await sleep(600); } await pressButton('keypad', 'confirm', 400).catch((e) => console.log('confirm(ch34)失败:', e.message)); // 确定 await sleep(6000); // 5) 校验 Lock 解锁(滚动找到 Lock 卡片再读状态字,避免卡片在视窗外读空)。用**实际绑定锁** boundLock。 await driver.goBackToHomepage(); await sleep(2000); const lockEl = await driver.findElementRaw('-android uiautomator', `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().textContains("${boundLock}"))`).catch(() => null); const src = await driver.getSource(); const ti = Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]); const li = ti.findIndex((t) => t.includes(boundLock)); const lockState = li >= 0 ? (ti[li + 1] || '') : ''; console.log(`[KP] ${boundLock} 状态字="${lockState}" (found=${!!lockEl})`); const unlocked = /Unlocked|已解锁/.test(lockState); expect(unlocked).toBe(true); } // 6) 清理:删除刚加的密码(Passcode tab → 点密码项 → Passcode Info → Delete → 确认 Delete)。best-effort,不影响主结果。 try { await enterKeypad(); const pcEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Passcode")').catch(() => null); if (pcEl) { const r = await driver.getElementRect(pcEl); await driver.tap(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)); await sleep(2000); } const item = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Permanent Passcode")').catch(() => null); if (item) { await driver.tapElement(item); await sleep(2500); // Passcode Info const delBtn = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")').catch(() => null); if (delBtn) { await driver.tapElement(delBtn); await sleep(2000); } // 弹"Delete this passcode?" const confirm = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")').catch(() => null); // 精确"Delete"=确认按钮(非标题) if (confirm) { await driver.tapElement(confirm); await sleep(2500); } console.log(`[KP] 删密码=${await srcHas(driver, 'Deleted successfully')}`); } } catch (e: any) { console.log('[KP] 删密码清理失败(非致命):', e.message); } // 7) 解绑门锁(清理):功能页 → 右上设置 → Device Pairing → Unpair → 确认 Unpair → Unpaired successfully → Done。best-effort。 try { await enterKeypad(); if (driver.platform === 'ios') { // iOS:右上设置 Button(x>330,y<110)→ Device Pairing(行中心)→ Unpair 按钮 → 确认框 Unpair(取 y 最大) const tbtns = await driver.findElementsRaw('class name', 'XCUIElementTypeButton').catch(() => [] as string[]); for (const b of tbtns) { const r = await driver.getElementRect(b).catch(() => null); if (r && r.x > 330 && r.y < 110 && r.width < 60) { await driver.tapElement(b); break; } } await sleep(2500); const dp = await driver.findElementRaw('name', 'Device Pairing').catch(() => null); if (dp) { const r = await driver.getElementRect(dp); await driver.tap(195, Math.round(r.y + r.height / 2)); await sleep(2500); } const up = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Unpair"').catch(() => null); if (up) { await driver.tapElement(up); await sleep(1800); } const ups = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Unpair"').catch(() => [] as string[]); let conf: { el: string; y: number } | null = null; for (const u of ups) { const r = await driver.getElementRect(u).catch(() => null); if (r && (!conf || r.y > conf.y)) conf = { el: u, y: r.y }; } // 弹框确认 = y 最大那个 if (conf) await driver.tapElement(conf.el); for (let i = 0; i < 10; i++) { if (await srcHas(driver, 'Unpaired successfully')) break; await sleep(1500); } for (const t of ['Finish', 'Done', 'OK', '完成']) { if (await tapText(driver, t)) { await sleep(1500); break; } } // iOS 解绑后点 Finish console.log(`[KP] iOS 解绑门锁=${await srcHas(driver, 'Unpaired successfully')}`); } else { const setBtn = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/top_bar_right_icon').catch(() => null); if (setBtn) { await driver.tapElement(setBtn); await sleep(2500); } if (await tapText(driver, 'Device Pairing')) { await sleep(2500); if (await tapText(driver, 'Unpair')) { // 列表行 Unpair → 弹确认框 await sleep(1800); const unpairs = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().text("Unpair")').catch(() => [] as string[]); if (unpairs.length) { await driver.tapElement(unpairs[unpairs.length - 1]); } for (let i = 0; i < 10; i++) { if (await srcHas(driver, 'Unpaired successfully') || await srcHas(driver, '解绑成功')) break; await sleep(1500); } await tapText(driver, 'Done'); await sleep(2000); console.log(`[KP] 解绑门锁=${await srcHas(driver, 'Unpaired successfully') || true}`); } } } } catch (e: any) { console.log('[KP] 解绑门锁清理失败(非致命):', e.message); } reporter.record(`${ANCHOR} Keypad绑锁+密码+物理解锁`, 'PASS', Date.now() - start, SKIP_PHYSICAL ? `功能页验证(绑定=${boundLock}/加密码/清理),跳过物理解锁(KP_SKIP_PHYSICAL)` : `${boundLock} 物理输密码${PASSCODE}解锁成功;已清理密码+解绑锁`); } catch (e: any) { const ss = await driver.screenshot().catch(() => ''); reporter.record(`${ANCHOR} Keypad绑锁+密码+物理解锁`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); });