import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; import { DeviceDriver } from '../../drivers/types'; import { createDriver } from '../../drivers/factory'; import { BotHelper } from '../../utils/bot-helper'; import { BOT_LOCATORS } from '../../locators/bot-locators'; import { TestReporter } from '../../utils/test-reporter'; import { getDeviceName } from '../../config/device.config'; import { sleep } from '../../utils/common'; import { setupResilientHooks } from "../../utils/common"; import { applyProtoNetwork } from '../../utils/common'; import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); const deviceName = getDeviceName('bot', 'BOT_DEVICE'); // 必测项控制步骤锚点(协议相关,由 PROTO 环境变量切换): BLE→15975#6e4uZSVe / WiFi→15974#Qt4hcgB4 // 该 step「点击控制Bot 不加密开&不加密关&加密按压」由 ON/OFF切换 + 加密按压 两个用例共同覆盖,回写时按 step 聚合 const PROTO = process.env.PROTO === 'wifi' ? 'wifi' : 'ble'; const CTRL = PROTO === 'wifi' ? '[P0][ONES:15974#Qt4hcgB4][wifi]' : '[P0][ONES:15975#6e4uZSVe][ble]'; describe('Bot Card - 首页卡片操作', () => { let driver: DeviceDriver; let bot: BotHelper; let reporter: TestReporter; setupResilientHooks(() => driver); beforeAll(async () => { driver = createDriver(); await driver.createSession(); bot = new BotHelper(driver); reporter = new TestReporter('Bot_Card', driver.platform.toUpperCase()); // 双协议前置:按 PROTO 切手机蓝牙/WiFi。切换本身偶发失败(adb dump 被杀), // 不应拖垮整个控制 suite —— 包成非致命(手机蓝牙通常已开,BLE 控制照常)。 if (process.env.SKIP_PROTO_NET !== '1') { try { await applyProtoNetwork(driver, PROTO); } catch (e: any) { console.log(`applyProtoNetwork 跳过(非致命): ${e.message}`); } } }); beforeEach(async () => { await driver.dismissPopupIfPresent(); await driver.goBackToHomepage(); await sleep(500); await driver.dismissPopupIfPresent(); }); afterAll(async () => { reporter.generate(); await driver.destroySession(); }); async function findBot(): Promise { // 34 设备首页 Bot 常在下方:滚到**找到 or 到底为止**(原来只滚 1 次 250px → 够不着 → 3.2s 秒"找不到")。 let botId = await driver.findDeviceCard(deviceName); let lastSig = ''; for (let i = 0; i < 16 && !botId; i++) { const src = await driver.getSource(); // 到底检测:可见设备名集合不再变(不用 source 切片——RN/原生首页末尾静态会误判) const sig = (src.match(/(?:name|label|text)="[^"]{2,40}"/g) || []).slice(-30).join('|'); if (sig && sig === lastSig) break; lastSig = sig; await driver.scrollDown(driver.platform === 'ios' ? 500 : 300); await sleep(800); botId = await driver.findDeviceCard(deviceName); } if (!botId) throw new Error(`找不到${deviceName}卡片`); return botId; } async function captureScreenshot(): Promise { try { return await driver.screenshot(); } catch { return undefined; } } async function tapBotAndWaitPopup(): Promise { const botId = await findBot(); await driver.tapElement(botId); await sleep(1500); if (driver.platform === 'android') return; const check = await driver.findElement(BOT_LOCATORS.settingsButton); if (!check) { const botId2 = await driver.findDeviceCard(deviceName); if (botId2) { await driver.tapElement(botId2); await sleep(1500); } } } async function navigateToModeSettings(): Promise { await tapBotAndWaitPopup(); if (driver.platform === 'ios') { const settingsId = await driver.findElement(BOT_LOCATORS.settingsButton); if (!settingsId) throw new Error('Settings弹窗未出现'); await driver.tapElement(settingsId); await sleep(2000); } const modeId = await driver.findElement(BOT_LOCATORS.modeItem); if (!modeId) throw new Error('Mode菜单未找到'); await driver.tapElement(modeId); await sleep(2000); await dismissPasswordDialogIfPresent(); } async function getCurrentMode(): Promise<'Press' | 'Switch' | 'Unknown'> { const source = await driver.getSource(); if (source.includes('In Switch Mode')) return 'Switch'; if (source.includes('In Press Mode')) return 'Press'; if (source.includes('Switch Mode')) return 'Switch'; if (source.includes('Press Mode')) return 'Press'; return 'Unknown'; } async function dismissPasswordDialogIfPresent(): Promise { const source = await driver.getSource(); if (source.includes('Enter password') || source.includes('Enter Password')) { const fields = await driver.findElementsRaw('class name', driver.platform === 'android' ? 'android.widget.EditText' : 'XCUIElementTypeSecureTextField'); if (fields.length > 0) { await driver.typeText(fields[0], '1234'); await sleep(500); const okEl = await driver.findElementRaw('name', 'OK') || await driver.findElementRaw('name', 'Confirm'); if (okEl) await driver.tapElement(okEl); await sleep(2000); } } } async function switchToMode(targetMode: 'Press Mode' | 'Switch Mode'): Promise { if (driver.platform === 'android') { // Tap the mode selector container to open mode dialog const sivMode = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/sivMode")'); if (!sivMode) return false; await driver.tapElement(sivMode); await sleep(2000); let targetId = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${targetMode}")`); if (!targetId) { await sleep(1500); targetId = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${targetMode}")`); } if (!targetId) { const cancelId = await driver.findElementRaw('name', 'Cancel'); if (cancelId) await driver.tapElement(cancelId); return false; } await driver.tapElement(targetId); await sleep(1500); const confirmId = await driver.findElementRaw('name', 'Confirm') || await driver.findElementRaw('name', 'OK'); if (confirmId) { await driver.tapElement(confirmId); await sleep(2000); } return true; } const modeLabel = await driver.findElementRaw('predicate string', 'name == "Mode" AND type == "XCUIElementTypeStaticText" AND visible == true'); if (!modeLabel) return false; const modeRect = await driver.getElementRect(modeLabel); await driver.tap(modeRect.x + modeRect.width + 50, modeRect.y + modeRect.height / 2); await sleep(1500); const targetId = await driver.findElementRaw('predicate string', `name == "${targetMode}" AND visible == true`); if (!targetId) { const cancelId = await driver.findElementRaw('name', 'Cancel'); if (cancelId) await driver.tapElement(cancelId); return false; } await driver.tapElement(targetId); await sleep(1500); const confirmId = await driver.findElementRaw('predicate string', 'name == "Confirm" OR name == "OK"'); if (confirmId) { await driver.tapElement(confirmId); await sleep(2000); } return true; } it(`首页找到${deviceName}卡片`, async () => { const start = Date.now(); try { const botId = await findBot(); const rect = await driver.getElementRect(botId); const detail = `位置: (${rect.x}, ${rect.y}) 尺寸: ${rect.width}x${rect.height}`; console.log(`${deviceName} ${detail}`); expect(rect.width).toBeGreaterThan(0); expect(rect.height).toBeGreaterThan(0); reporter.record(`首页找到${deviceName}卡片`, 'PASS', Date.now() - start, detail); } catch (e: any) { const ss = await captureScreenshot(); reporter.record(`首页找到${deviceName}卡片`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it('点击Bot卡片 → 弹出菜单包含Settings', async () => { const start = Date.now(); try { await tapBotAndWaitPopup(); if (driver.platform === 'android') { const modeId = await driver.findElement(BOT_LOCATORS.modeItem); const passcodeId = await driver.findElement(BOT_LOCATORS.passcodeItem); const settingsText = await driver.findElementRaw('name', 'Settings'); const detail = `Settings页: Settings=${!!settingsText}, Mode=${!!modeId}, Passcode=${!!passcodeId}`; console.log(`弹窗按钮: ${detail}`); expect(settingsText || modeId).not.toBeNull(); reporter.record('弹出菜单验证', 'PASS', Date.now() - start, detail); } else { const settingsId = await driver.findElement(BOT_LOCATORS.settingsButton); const onId = await driver.findElement(BOT_LOCATORS.onButton); const offId = await driver.findElement(BOT_LOCATORS.offButton); const pressBtn = await driver.findElementRaw('name', 'Press'); const pressOnce = await driver.findElementRaw('predicate string', 'name CONTAINS "Press"'); const detail = `Settings=${!!settingsId}, ON=${!!onId}, OFF=${!!offId}, Press=${!!pressBtn || !!pressOnce}`; console.log(`弹窗按钮: ${detail}`); expect(settingsId).not.toBeNull(); reporter.record('弹出菜单验证', 'PASS', Date.now() - start, detail); } } catch (e: any) { const ss = await captureScreenshot(); reporter.record('弹出菜单验证', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it('切换设备模式: Switch → Press → Switch', async () => { const start = Date.now(); try { await navigateToModeSettings(); const modeBefore = await getCurrentMode(); console.log('切换前模式:', modeBefore); const targetMode = modeBefore === 'Switch' ? 'Press Mode' : 'Switch Mode'; const switched1 = await switchToMode(targetMode as any); expect(switched1).toBe(true); await sleep(1000); const modeAfter = await getCurrentMode(); console.log('切换后模式:', modeAfter); expect(modeAfter).not.toBe(modeBefore); // Switch back to original const restoreMode = modeBefore === 'Switch' ? 'Switch Mode' : 'Press Mode'; await switchToMode(restoreMode as any); await sleep(1000); const modeFinal = await getCurrentMode(); console.log('还原模式:', modeFinal); const detail = `${modeBefore} → ${modeAfter} → ${modeFinal}`; reporter.record('切换设备模式', 'PASS', Date.now() - start, detail); } catch (e: any) { const ss = await captureScreenshot(); reporter.record('切换设备模式', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it('Switch模式 - ON/OFF切换', { timeout: 150000 }, async () => { const start = Date.now(); // 从控制弹窗读状态:弹窗显示状态文本 "On"/"Off"(与按钮 "ON"/"OFF" 大小写不同) const readStatusFromPopup = async (): Promise<'on' | 'off' | 'unknown'> => { const s = await driver.getSource(); if (/text="Off"/.test(s)) return 'off'; if (/text="On"/.test(s)) return 'on'; return 'unknown'; }; try { // 前置:用模式切换脚本确保 Switch 模式(Press 模式无 ON/OFF) await navigateToModeSettings(); if ((await getCurrentMode()) !== 'Switch') { const ok = await switchToMode('Switch Mode'); console.log('切换到 Switch Mode:', ok); await sleep(2500); } await driver.goBackToHomepage(); await sleep(2000); // 打开控制弹窗读初始状态 await tapBotAndWaitPopup(); const before = await readStatusFromPopup(); console.log('弹窗初始状态:', before); expect(before).not.toBe('unknown'); // Switch 模式弹窗应能读到 On/Off // 3. 点相反按钮控制 const targetBtn = before === 'off' ? 'ON' : 'OFF'; const btn = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${targetBtn}")`); expect(btn).not.toBeNull(); await driver.tapElement(btn!); await sleep(6000); // 等 BLE 命令 + 状态回报 // 4. 重新开弹窗读状态,校验翻转(状态没更新=设备连接问题) await driver.goBackToHomepage(); await sleep(2000); await tapBotAndWaitPopup(); const after = await readStatusFromPopup(); console.log('操作后状态:', after); expect(after).not.toBe('unknown'); expect(after).not.toBe(before); reporter.record(`${CTRL} 不加密开/关`, 'PASS', Date.now() - start, `${before} → ${after}(弹窗校验)`); // 还原 const restoreBtn = before === 'off' ? 'OFF' : 'ON'; const rb = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${restoreBtn}")`); if (rb) { await driver.tapElement(rb); await sleep(5000); } } catch (e: any) { const ss = await captureScreenshot(); reporter.record(`${CTRL} 不加密开/关`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it('设置页 - 验证所有菜单项', async () => { const start = Date.now(); try { await tapBotAndWaitPopup(); // Android:点卡片通常直接进设置页;若是小弹窗(没出现 Mode 菜单),再点 "Settings" 进入 if (driver.platform === 'ios') { const settingsId = await driver.findElement(BOT_LOCATORS.settingsButton); expect(settingsId).not.toBeNull(); await driver.tapElement(settingsId!); await sleep(2000); } else { const hasMenu = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Mode")'); if (!hasMenu) { const s = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Settings")'); if (s) { await driver.tapElement(s); await sleep(2000); } } } const items = ['modeItem', 'passcodeItem', 'schedulesItem', 'logsItem', 'nfcItem'] as const; const found: string[] = []; const missing: string[] = []; for (const key of items) { const id = await driver.findElement(BOT_LOCATORS[key]); if (id) found.push(BOT_LOCATORS[key].name); else missing.push(BOT_LOCATORS[key].name); } await driver.scrollDown(400); await sleep(1000); const bottomItems = ['firmwareItem', 'deviceInfoItem', 'deleteItem'] as const; for (const key of bottomItems) { const id = await driver.findElement(BOT_LOCATORS[key]); if (id) found.push(BOT_LOCATORS[key].name); else missing.push(BOT_LOCATORS[key].name); } const detail = `找到 ${found.length}/${found.length + missing.length} 项`; console.log(`${detail}: ${found.join(', ')}`); expect(found.length).toBeGreaterThanOrEqual(6); reporter.record('设置页菜单项验证', 'PASS', Date.now() - start, detail); } catch (e: any) { const ss = await captureScreenshot(); reporter.record('设置页菜单项验证', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); // ==================== 加密相关 ==================== async function enterSettings(): Promise { await tapBotAndWaitPopup(); // 在线时点卡片是控制弹窗(有 Settings 入口,无 Passcode)→ 点 Settings 进设置页;离线时已是设置页 const onSettings = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Passcode")'); if (!onSettings) { const settings = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Settings")') || await driver.findElementRaw('name', 'Settings'); if (settings) { await driver.tapElement(settings); await sleep(2000); } } } async function scrollToAndTap(name: string): Promise { if (driver.platform === 'android') { const el = await driver.findElementRaw('-android uiautomator', `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text("${name}"))`); if (!el) return false; await driver.tapElement(el); return true; } let el = await driver.findElementRaw('name', name); if (el) { const rect = await driver.getElementRect(el); if (rect.y > 750) { await driver.scrollDown(300); await sleep(500); el = await driver.findElementRaw('name', name); } } else { await driver.scrollDown(300); await sleep(500); el = await driver.findElementRaw('name', name); } if (!el) return false; const rect = await driver.getElementRect(el); await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); return true; } it('设置加密密码', { timeout: 90000 }, async () => { const start = Date.now(); try { await enterSettings(); // 进 Passcode 页 const tapped = await scrollToAndTap('Passcode'); expect(tapped).toBe(true); await sleep(2000); await dismissPasswordDialogIfPresent(); // 若已是 "Not set" 才走设密码流程(已设则跳过设置,直接按"无 Not set"判通过) let pcSrc = await driver.getSource(); if (pcSrc.includes('Not set')) { const pwItem = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/passwordItemView")'); expect(pwItem).not.toBeNull(); await driver.tapElement(pwItem!); await sleep(2000); const e1 = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editPwd")'); const e2 = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editPwdRepeat")'); expect(e1).not.toBeNull(); expect(e2).not.toBeNull(); await driver.typeText(e1!, '1234'); await sleep(400); await driver.typeText(e2!, '1234'); await sleep(600); // 不要 goBack(Android BACK 会关对话框)。直接点 btnConfirm(在键盘上方) const confirm = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/btnConfirm")'); expect(confirm).not.toBeNull(); await driver.tapElement(confirm!); await sleep(4000); await driver.dismissPopupIfPresent(); } else { console.log('密码已设置(非 Not set),按"无 Not set"判通过'); } // 通过判据:密码页不再显示 "Not set" const after = await driver.getSource(); const stillNotSet = after.includes('Not set'); console.log('设密码后仍 Not set:', stillNotSet); expect(stillNotSet).toBe(false); reporter.record('设置加密密码', 'PASS', Date.now() - start, '密码已设置(密码页无 Not set)'); } catch (e: any) { const ss = await captureScreenshot(); reporter.record('设置加密密码', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it('加密Bot-切换为Press模式', async () => { const start = Date.now(); try { await sleep(1000); const botId = await findBot(); let source = await driver.getSource(); if (source.includes('Press Mode')) { console.log('当前已是Press模式'); reporter.record('加密-切换Press', 'PASS', Date.now() - start, '当前已是Press Mode'); return; } await driver.tapElement(botId); await sleep(1000); const settingsId = await driver.findElement(BOT_LOCATORS.settingsButton); if (!settingsId) throw new Error('Settings按钮未找到'); await driver.tapElement(settingsId); await sleep(1500); const modeEl = await driver.findElementRaw('name', 'Mode'); expect(modeEl).not.toBeNull(); await driver.tapElement(modeEl!); await sleep(2000); const switched = await switchToMode('Press Mode'); expect(switched).toBe(true); await sleep(3000); source = await driver.getSource(); const isPress = source.includes('Press Mode'); console.log('加密后模式:', isPress ? 'Press Mode' : 'other'); reporter.record('加密-切换Press', 'PASS', Date.now() - start, `Press Mode=${isPress}`); } catch (e: any) { const ss = await captureScreenshot(); reporter.record('加密-切换Press', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it('加密Bot-按压操作', async () => { const start = Date.now(); try { const botId = await findBot(); const rect = await driver.getElementRect(botId); // Tap button area on card await driver.tap(rect.x + rect.width - 30, rect.y + rect.height / 2); await sleep(2000); // Check if passcode input appears let source = await driver.getSource(); if (source.includes('Passcode') || source.includes('Password') || source.includes('Enter')) { // Try text field input const textFields = await driver.findElementsRaw('class name', 'XCUIElementTypeTextField'); const secureFields = await driver.findElementsRaw('class name', 'XCUIElementTypeSecureTextField'); const field = textFields[0] || secureFields[0]; if (field) { await driver.typeText(field, '1234'); await sleep(1000); const confirmEl = await driver.findElementRaw('name', 'Confirm') || await driver.findElementRaw('name', 'OK'); if (confirmEl) await driver.tapElement(confirmEl); } await sleep(5000); console.log('输入密码1234后执行按压'); } else { await sleep(8000); console.log('直接执行按压(无密码弹窗)'); } source = await driver.getSource(); const stillPress = source.includes('Press Mode'); console.log('按压后状态:', stillPress ? 'Press Mode' : '已执行'); reporter.record(`${CTRL} 加密按压`, 'PASS', Date.now() - start, `加密按压完成, Press=${stillPress}`); } catch (e: any) { const ss = await captureScreenshot(); reporter.record(`${CTRL} 加密按压`, 'FAIL', Date.now() - start, e.message, ss); throw e; } }); it('删除加密密码', { timeout: 90000 }, async () => { const start = Date.now(); try { await enterSettings(); const tapped = await scrollToAndTap('Passcode'); expect(tapped).toBe(true); await sleep(2000); // On Android, entering Passcode page may require password await dismissPasswordDialogIfPresent(); await sleep(1000); // Check if password is set (Delete/Clear button visible) let deleteEl = await driver.findElementRaw('name', 'Delete'); if (!deleteEl && driver.platform === 'android') { deleteEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Clear")'); if (!deleteEl) { await driver.scrollDown(200); await sleep(500); deleteEl = await driver.findElementRaw('name', 'Delete'); } } if (deleteEl) { await driver.tapElement(deleteEl); await sleep(2000); // 确认框:btnConfirm / OK / Delete const confirm = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/btnConfirm")'); if (confirm) { await driver.tapElement(confirm); await sleep(3000); } else { for (const btn of ['OK', 'Confirm', 'Delete', 'Done']) { const el = await driver.findElementRaw('name', btn); if (el) { await driver.tapElement(el); await sleep(3000); break; } } } } else { console.log('Delete按钮未找到,密码可能未设置'); } await sleep(1500); await driver.dismissPopupIfPresent(); // 严格校验:删除后密码页应重新显示 "Not set"(与设置对称) const source = await driver.getSource(); const isDeleted = source.includes('Not set'); console.log('删除后是否 Not set:', isDeleted); expect(isDeleted).toBe(true); reporter.record('删除加密密码', 'PASS', Date.now() - start, '密码已删除(行值回到 Not set)'); } catch (e: any) { const ss = await captureScreenshot(); reporter.record('删除加密密码', 'FAIL', Date.now() - start, e.message, ss); throw e; } }); });