AI_UIAutomation/tests/bot/bot_card.test.ts

588 lines
24 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 * 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(`首页找到${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<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 {
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;
}
});
});