AI_UIAutomation/tests/bot/bot_card.test.ts

526 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { tapHomeCardTopRight, getHomeCardStatus } 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<string> {
// 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<string | undefined> {
try { return await driver.screenshot(); } catch { return undefined; }
}
async function tapBotAndWaitPopup(): Promise<void> {
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<void> {
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<void> {
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<boolean> {
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('切换设备模式: 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 校验翻转;点卡片右上角开关钮(controlBtn)切换。两端统一走首页卡片开关。
const readCard = async (): Promise<'on' | 'off' | 'unknown'> => {
const s = (await getHomeCardStatus(driver, deviceName, { resetPosition: 'currentThenTop' })).toLowerCase();
if (s.includes('off')) return 'off';
if (s.includes('on')) 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);
const before = await readCard();
console.log('卡片初始状态:', before);
expect(before).not.toBe('unknown'); // 需连 Hub 且 Switch 模式,卡片才显示 On/Off
await tapHomeCardTopRight(driver, deviceName, { resetPosition: 'currentThenTop', logOnFail: true });
await sleep(6000); // 等 BLE 命令 + 状态回报
const after = await readCard();
console.log('操作后状态:', after);
expect(after).not.toBe('unknown');
expect(after).not.toBe(before);
reporter.record(`${CTRL} 不加密开/关`, 'PASS', Date.now() - start, `${before} → ${after}(卡片校验)`);
// 还原
await tapHomeCardTopRight(driver, deviceName, { resetPosition: 'currentThenTop', logOnFail: true });
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<void> {
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<boolean> {
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 {
await tapHomeCardTopRight(driver, deviceName, { resetPosition: 'currentThenTop', logOnFail: true });
// 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;
}
});
});