834 lines
42 KiB
TypeScript
834 lines
42 KiB
TypeScript
import { DeviceDriver } from '../../drivers/types';
|
||
import { sleep, waitForElement, waitForSource, scrollUntilFound } from './element.helper';
|
||
import { ensureHomeTab, scrollHomeToTopIOS, navigateToAddPage, navigateThroughWizard, restartAndroidApp } from './navigation.helper';
|
||
import { enterPairingMode } from './relay.helper';
|
||
import { saveAddedDevice, clearAddedDevices } from './device-registry.helper';
|
||
|
||
export interface AddDeviceOptions {
|
||
categoryName: string;
|
||
categoryContains?: boolean; // 品类入口默认全匹配;组合名入口(如 "Key Vision/Key Vision Pro")需设 true 用 textContains
|
||
deviceKeyword: string;
|
||
scanTimeout?: number;
|
||
wizardButtons?: string[];
|
||
connectionKeywords?: string[];
|
||
preSelectSteps?: (driver: DeviceDriver) => Promise<void>;
|
||
postConnectionSteps?: (driver: DeviceDriver) => Promise<void>;
|
||
skipNextButton?: boolean;
|
||
skipScanStep?: boolean;
|
||
registryCategory?: string; // 成功后把首页实际名写入注册表的 key(供控制用例按注册名找卡片,不写死);如 'eaveLight'
|
||
namePattern?: string; // 首页实际名正则(注册用真实名,如 'Permanent Outdoor Lights\\s*\\w{0,4}');不传则注册 deviceKeyword
|
||
}
|
||
|
||
const DEFAULT_CONNECTION_KEYWORDS = [
|
||
'Initial Setup', 'Start Using', 'Done', 'added successfully',
|
||
'Got it', 'cloud service', 'Pick a room', 'Display Type',
|
||
];
|
||
|
||
// 'Save' 必须在表内:部分设备(如 Keypad Touch)配对后停在"输入设备名"页,需点 Save 才提交并跳首页;漏掉则设备未保存、首页校验失败。
|
||
const DEFAULT_WIZARD_BUTTONS = ['Use now', 'Start Using', 'Done', 'Save', 'Got it', 'OK', 'Skip', 'Next'];
|
||
|
||
// "连接已真正开始"的标志(用于判断配对是否触发);**不含引导页常驻的 Next/Got it**,避免误判已连。
|
||
const CONNECT_STARTED_KEYWORDS = [
|
||
'Connecting', 'connected', 'Connect Device', 'Discover device', 'Initial Setup',
|
||
'added successfully', 'Added successfully', 'Pick a room', 'Select Room', 'Select Mode',
|
||
// WiFi 配网页 = 连接已开始(尤其 plug/hub:Other Pairing+Yes 后立即到配网页,不加这些会误判未连接→白跑满重试~110s)
|
||
'Configure Wi-Fi', 'Wi-Fi Settings', 'Wi-Fi password', 'Enter Wi-Fi',
|
||
];
|
||
|
||
export async function isDeviceOnHomepage(driver: DeviceDriver, keyword: string): Promise<boolean> {
|
||
await ensureHomeTab(driver);
|
||
let source = await driver.getSource();
|
||
if (source.includes(keyword)) return true;
|
||
await driver.scrollDown(300);
|
||
await sleep(800);
|
||
source = await driver.getSource();
|
||
return source.includes(keyword);
|
||
}
|
||
|
||
/**
|
||
* 在首页按正则找设备卡,返回**实际显示名**(如 "Meter 0B"),找不到返回 null。
|
||
* 用于"设备实际名与配置默认名不符"时拿到真名(写入注册表作功能页入口)。
|
||
*/
|
||
export async function findDeviceNameOnHomepage(driver: DeviceDriver, pattern: string): Promise<string | null> {
|
||
// 添加前的"设备已存在?"预检:默认**跳过**(用户要求所有添加用例去掉添加前查找,提速——预检会滚动首页很慢,
|
||
// 且 reset 已清空时纯属浪费、还引发过误 SKIP)。需要恢复(单跑想已存在即 SKIP)时设 ADD_PRECHECK=1。
|
||
// 注:添加后的首页**校验**走 addDeviceViaBLE/addDeviceWithSerialPairing 内部独立循环,不受此影响。
|
||
if (process.env.ADD_PRECHECK !== '1') return null;
|
||
// 先退出可能残留的添加向导/Attention 弹框(否则首页被遮挡)
|
||
for (const t of ['Return Home', 'Quit', 'Exit', 'Leave']) {
|
||
const el = driver.platform === 'android'
|
||
? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`)
|
||
: await driver.findElementRaw('name', t);
|
||
if (el) { await driver.tapElement(el); await sleep(2000); break; }
|
||
}
|
||
await driver.dismissPopupIfPresent();
|
||
await ensureHomeTab(driver);
|
||
await scrollHomeToTopIOS(driver); // iOS 回最顶再下滑找(HomeScene tab 不回顶,否则停在中间漏掉靠下的设备卡)
|
||
const re = new RegExp(pattern);
|
||
for (let i = 0; i < 14; i++) {
|
||
const source = await driver.getSource();
|
||
const m = source.match(re);
|
||
if (m) return m[0];
|
||
await driver.scrollDown(400);
|
||
await sleep(500);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export async function selectDeviceCategory(driver: DeviceDriver, categoryName: string, scrollHint = 0, allowContains = false): Promise<boolean> {
|
||
if (driver.platform === 'android') {
|
||
// 默认**全匹配**(exact text);仅在 allowContains=true 时回退 textContains(用于组合名品类,如 "Keypad Vision/Keypad Vision Pro")。
|
||
// 不要默认开 contains:会把 "Remote" 误匹配到 "Universal Remote" 等错误入口 → 流程卡死超时。
|
||
// 找品类滑动用**快速甩动**(0.18s)而非默认 scrollDown 的 0.5s 慢拖——慢拖是"滑动看着很慢"的根子。
|
||
// 甩动距离仍 < 一屏(900<屏高),且每次滑后都重查 → 不会跳过目标。
|
||
const sz = await driver.getWindowSize().catch(() => ({ width: 1080, height: 2280 }));
|
||
const fling = async (dist = 650) => {
|
||
const cx = Math.round(sz.width / 2);
|
||
// 适中速度 0.3s + 距离收到 650:0.18s 过快→动量过冲、目标品类在两次 find 之间被跳过(找不到入口);
|
||
// 0.5s 又太慢。0.3s 既比慢拖快、又不过冲,每次只滚约 650px(更细、不跳过)。
|
||
await driver.swipe(cx, Math.round(sz.height * 0.72), cx, Math.round(sz.height * 0.72 - dist), 0.3);
|
||
};
|
||
const esc = categoryName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const find = async () => {
|
||
// textMatches((?i)esc) 已是大小写不敏感的**全字**匹配,覆盖 exact → 不再单发 exact 查询(省一次 WDA 往返,每步更快);
|
||
// 仍整串匹配,不会误匹配子串(如 Remote→Universal Remote)。
|
||
const ci = await driver.findElementRaw('-android uiautomator', `new UiSelector().textMatches("(?i)${esc}")`).catch(() => null);
|
||
if (ci) return ci;
|
||
if (allowContains) return await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${categoryName}")`);
|
||
return null;
|
||
};
|
||
// 品类网格异步加载:短轮询等其渲染(idle=1s 下通常很快;Meter 等非热门品类首屏不可见,少等即可)
|
||
let el = await find();
|
||
for (let i = 0; i < 2 && !el; i++) { await sleep(400); el = await find(); }
|
||
// 首屏没有 → 手动下滑查找(不用慢的 UiScrollable.scrollIntoView,它每微滚都重查整棵树 ~12s)。
|
||
// 已知大概滑几次的品类(scrollHint):先快滑 hint-1 次不校验,再边滑边找——大幅省时。
|
||
if (!el && scrollHint > 1) {
|
||
for (let i = 0; i < scrollHint - 1; i++) await fling();
|
||
el = await find();
|
||
}
|
||
let swipes = 0;
|
||
for (let i = 0; i < 14 && !el; i++) {
|
||
await fling();
|
||
swipes++;
|
||
el = await find(); // idle=1s 下 find 很快,无需额外 sleep
|
||
}
|
||
if (!el) { console.log(`FAIL: no ${categoryName} option`); return false; }
|
||
if (scrollHint <= 1 && swipes) console.log(`[hint] ${categoryName} 实际滑动 ${(scrollHint || 0) + swipes} 次找到(可设为该品类 scrollHint 提速)`);
|
||
// 防误点:元素若贴近屏幕底部(系统返回键/手势区,屏高 2280),点其中心会命中返回键→退回。
|
||
// 先小幅下滑把它抬起再点(如 Water Leak Detector 滑到底正好压在返回键上,导致选品类变成返回)。
|
||
try {
|
||
const rect = await driver.getElementRect(el);
|
||
if (rect && rect.y + rect.height > 2000) {
|
||
await driver.scrollDown(400); await sleep(500);
|
||
const el2 = await find();
|
||
if (el2) el = el2;
|
||
}
|
||
} catch { /* 取不到 rect 就直接点 */ }
|
||
await driver.tapElement(el);
|
||
// 点击品类后进入 免责/引导/扫描/连接 页(部分页标题仍为 "Add Device" 且不显示品类名,
|
||
// 如 Presence/Safety Alarm 的 "Press and hold...Connect Device" 引导页)→ 轮询等渲染,命中任一标志即视为已进入。
|
||
let entered = false;
|
||
for (let t = 0; t < 6 && !entered; t++) {
|
||
await sleep(700);
|
||
const src = await driver.getSource();
|
||
entered = src.includes('Disclaimer') || src.includes('Before Using')
|
||
|| src.includes('Connect Device') || src.includes('Press and hold') || src.includes('press the device')
|
||
|| src.includes('indicator light') || src.includes('Scanning') || src.includes('searching')
|
||
|| src.includes('Next') || src.includes(categoryName) || !src.includes('Add Device');
|
||
}
|
||
if (!entered) { console.log(`FAIL: cannot select category ${categoryName}`); return false; }
|
||
return true;
|
||
}
|
||
|
||
// iOS - 先在当前屏查找,找不到才滑动。
|
||
// ★ 偶现"找不到入口"根因:iOS 的 driver.scrollDown 是 0.1s **大甩动**(忽略距离参数,固定 0.8h→0.2h),
|
||
// 动量过冲 + 只等 350ms → 目标品类在两次 find 之间被甩过去(Android 早期同坑,已用 0.3s 适中速度修掉)。
|
||
// 修复(对齐 Android 稳定做法):① 进页先等目录加载;② 改**可控慢滑**(小步 ~0.22h + 0.4s + 沉降 650ms),不过冲。
|
||
const { width: catW, height: catH } = await driver.getWindowSize().catch(() => ({ width: 390, height: 844 }));
|
||
const slowScroll = async () => {
|
||
await driver.swipe(Math.round(catW / 2), Math.round(catH * 0.60), Math.round(catW / 2), Math.round(catH * 0.38), 0.4);
|
||
await sleep(650); // 等动量沉降稳定再 find(短等待会在列表还在动时查 → 漏)
|
||
};
|
||
const iosFind = async (): Promise<string | null> => {
|
||
// 一条 predicate 覆盖 StaticText/Other/通用(name 或 label)。⚠️ 用**大小写不敏感** `==[c]`:
|
||
// iOS 品类名是 Title Case(如 "RGBIC Neon Wire Rope Light"),用例常写小写 → 敏感匹配会"滚到底也找不到入口"。
|
||
let el = await driver.findElementRaw('predicate string', `name ==[c] "${categoryName}" OR label ==[c] "${categoryName}"`).catch(() => null);
|
||
if (!el && allowContains) {
|
||
el = await driver.findElementRaw('predicate string', `name CONTAINS[c] "${categoryName}" OR label CONTAINS[c] "${categoryName}"`).catch(() => null);
|
||
}
|
||
return el;
|
||
};
|
||
// 进 Add Device 目录页后等其加载完(页面未渲染完就查/滑 → 偶现找不到入口)。轮询到出现任意品类项或稳定为止。
|
||
for (let i = 0; i < 8; i++) {
|
||
const src = await driver.getSource();
|
||
if (/Add Manually|Scanning|Nearby|Bluetooth|Robot|Meter|Plug|Lock|Cam|Hub|Bot|Sensor|Light/i.test(src)) break;
|
||
await sleep(600);
|
||
}
|
||
// 已知滑动次数:先快滑预滑 hint-1 次不校验(当前屏没有时才预滑),再边滑边找——省掉前几屏的查询。
|
||
if (scrollHint > 1 && !(await iosFind())) {
|
||
for (let i = 0; i < scrollHint - 1; i++) { await slowScroll(); }
|
||
}
|
||
for (let i = 0; i < 16; i++) {
|
||
let el = await iosFind();
|
||
if (el) {
|
||
const visible = await driver.getElementAttribute(el, 'visible').catch(() => 'true');
|
||
if (String(visible) !== 'true' && String(visible) !== '1') {
|
||
await slowScroll();
|
||
continue;
|
||
}
|
||
// 防贴底误点:卡片太靠下(y>700/844,易被底部栏挡或点到边缘)→ 先小幅上滑抬起,重取再点
|
||
const rect = await driver.getElementRect(el).catch(() => null);
|
||
if (rect && rect.y > 700) {
|
||
await slowScroll();
|
||
const el2 = await iosFind(); if (el2) el = el2;
|
||
}
|
||
await driver.tapElement(el); // 真实坐标 tap(比 /click 稳)
|
||
await sleep(3000);
|
||
return true;
|
||
}
|
||
await slowScroll();
|
||
}
|
||
console.log(`FAIL: no ${categoryName} option`);
|
||
return false;
|
||
}
|
||
|
||
export async function waitForDeviceInScan(
|
||
driver: DeviceDriver,
|
||
deviceKeyword: string,
|
||
timeout = 30000
|
||
): Promise<string | null> {
|
||
if (driver.platform === 'android') {
|
||
const start = Date.now();
|
||
while (Date.now() - start < timeout) {
|
||
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${deviceKeyword}")`);
|
||
if (el) return el;
|
||
await sleep(1000);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// iOS
|
||
return await waitForElement(driver, 'name', deviceKeyword, timeout);
|
||
}
|
||
|
||
export async function waitForConnection(
|
||
driver: DeviceDriver,
|
||
keywords?: string[],
|
||
timeout = 30000
|
||
): Promise<boolean> {
|
||
const connectionKeywords = keywords || DEFAULT_CONNECTION_KEYWORDS;
|
||
const start = Date.now();
|
||
while (Date.now() - start < timeout) {
|
||
const source = await driver.getSource();
|
||
for (const kw of connectionKeywords) {
|
||
if (source.includes(kw)) return true;
|
||
}
|
||
await driver.dismissPopupIfPresent(); // 连接/配网过程中途突然冒出的弹框,顺手关掉
|
||
await sleep(2000);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export async function addDeviceViaBLE(driver: DeviceDriver, options: AddDeviceOptions): Promise<boolean> {
|
||
const {
|
||
categoryName,
|
||
deviceKeyword,
|
||
scanTimeout = 30000,
|
||
wizardButtons = DEFAULT_WIZARD_BUTTONS,
|
||
connectionKeywords,
|
||
preSelectSteps,
|
||
postConnectionSteps,
|
||
skipNextButton = false,
|
||
skipScanStep = false,
|
||
registryCategory,
|
||
namePattern,
|
||
} = options;
|
||
|
||
// Step 1: Navigate to Add Device page
|
||
const addDevicePage = await navigateToAddPage(driver, 'Device');
|
||
if (!addDevicePage) { console.log('FAIL: cannot navigate to Add Device'); return false; }
|
||
|
||
// Step 2: Select device category
|
||
const catSelected = await selectDeviceCategory(driver, categoryName, 0, !!options.categoryContains);
|
||
if (!catSelected) { console.log(`FAIL: cannot select category ${categoryName}`); return false; }
|
||
|
||
// Optional: product-specific pre-select steps
|
||
if (preSelectSteps) await preSelectSteps(driver);
|
||
|
||
// Step 3: Wait for device in BLE scan (skip for auto-connect devices like AI Hub)
|
||
if (!skipScanStep) {
|
||
const deviceEl = await waitForDeviceInScan(driver, deviceKeyword, scanTimeout);
|
||
if (!deviceEl) {
|
||
console.log(`FAIL: ${deviceKeyword} not found in BLE scan`);
|
||
return false;
|
||
}
|
||
await driver.tapElement(deviceEl);
|
||
await sleep(500);
|
||
|
||
// Step 4: Tap Next (some products skip this)
|
||
if (!skipNextButton) {
|
||
if (driver.platform === 'android') {
|
||
const nextEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Next")');
|
||
if (nextEl) await driver.tapElement(nextEl);
|
||
} else {
|
||
const nextEl = await driver.findElementRaw('name', 'Next');
|
||
if (nextEl) await driver.tapElement(nextEl);
|
||
}
|
||
await sleep(2000);
|
||
}
|
||
}
|
||
|
||
// Step 5: Wait for connection(连接失败时点 App 的"重试"再等,最多重试 2 次)
|
||
let connected = await waitForConnection(driver, connectionKeywords);
|
||
for (let r = 1; !connected && r <= 2; r++) {
|
||
let retryBtn: string | null = null;
|
||
for (const t of ['Retry', '重试', 'Try Again', 'Try again', 'Reconnect', 'Reconnect device', 'Connect again', 'Connect Again']) {
|
||
retryBtn = driver.platform === 'android'
|
||
? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`).catch(() => null)
|
||
: await driver.findElementRaw('name', t).catch(() => null);
|
||
if (retryBtn) break;
|
||
}
|
||
if (!retryBtn) break; // 当前页没有"重试"按钮,放弃
|
||
console.log(`连接失败,点击重试(第 ${r}/2 次)`);
|
||
await driver.tapElement(retryBtn);
|
||
await sleep(3000);
|
||
connected = await waitForConnection(driver, connectionKeywords);
|
||
}
|
||
if (!connected) { console.log('FAIL: connection timeout'); return false; }
|
||
|
||
// Step 5.5: Post-connection steps (e.g. WiFi setup for cameras)
|
||
if (postConnectionSteps) await postConnectionSteps(driver);
|
||
|
||
// Step 6: Handle Initial Setup (iOS)
|
||
if (driver.platform === 'ios') {
|
||
const source = await driver.getSource();
|
||
if (source.includes('Initial Setup')) {
|
||
const setupNext = await driver.findElementRaw('name', 'Next');
|
||
if (setupNext) { await driver.tapElement(setupNext); await sleep(2000); }
|
||
}
|
||
}
|
||
|
||
// Step 7: Navigate through wizard
|
||
await navigateThroughWizard(driver, wizardButtons);
|
||
await driver.dismissPopupIfPresent();
|
||
|
||
// Step 8: Verify on homepage
|
||
await ensureHomeTab(driver);
|
||
await scrollHomeToTopIOS(driver); // iOS 回最顶再下滑找(否则漏掉靠下的新设备卡)
|
||
await sleep(2000);
|
||
const re = namePattern ? new RegExp(namePattern) : null;
|
||
for (let i = 0; i < 14; i++) {
|
||
const source = await driver.getSource();
|
||
// 成功:命中即把**首页实际名**(优先 namePattern 真实名,否则 deviceKeyword)写入注册表,供控制用例按注册名找卡片(不写死)
|
||
if (re) { const m = source.match(re); if (m) { if (registryCategory) saveAddedDevice(registryCategory, m[0]); return true; } }
|
||
if (source.includes(deviceKeyword)) { if (registryCategory) saveAddedDevice(registryCategory, deviceKeyword); return true; }
|
||
await driver.scrollDown(400);
|
||
await sleep(800);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export interface SerialPairingAddOptions {
|
||
categoryName: string;
|
||
categoryContains?: boolean; // 品类入口默认全匹配;组合名入口(如 "Key Vision/Key Vision Pro")需设 true 用 textContains // 添加目录里的品类/产品名(如 'Meter' / 'Remote')
|
||
categoryScrollHint?: number; // 该品类在添加目录里大概滑动几次可见(提速:先快滑 hint-1 次再校验);日志会打印实测值
|
||
deviceKeyword: string; // 首页校验关键字(如 'Meter 3M')
|
||
relayDevice: string; // config/relay.config 的设备 key(如 'meter' / 'remote')
|
||
registryCategory?: string; // 成功后把设备名写入注册表的品类 key(如 'meter');默认用 relayDevice
|
||
namePattern?: string; // 首页实际设备名的正则(如 'Meter \\w+');匹配到则以实际名回写注册表
|
||
preferNamePattern?: boolean; // 成功判据/回写时**优先用 namePattern 匹配的真实名**,再回退 capturedName/deviceKeyword。
|
||
// 用于"加时内联改名"的设备(curtain/灯类):默认名(capturedName)是改名后名("Curtain Auto")的子串("Curtain"),
|
||
// 不开此项会被 source.includes 松匹配先存成通用名 → 注册表/型号区分丢失。
|
||
beforePairing?: (driver: DeviceDriver) => Promise<void>; // 过免责声明后、按继电器配对前的额外步骤(如 outdoor meter 套件先点 "Add Meter")
|
||
pairRetries?: number; // 配对未触发(按完没连上)时的最大尝试轮数,默认 3
|
||
pairPollIters?: number; // 每轮检测轮询次数(窗口=次数×1.5s),默认 12(~18s);remote 检测慢需调大
|
||
repressOnRetry?: boolean; // 重试时是否重按继电器;默认 true。切换式配对键(如 remote 凸+凹,按一次进/再按退)须设 false——只按一次,重试只重点 Next
|
||
tapOtherPairing?: boolean; // 配对未自动触发时,点"其他配对方式/Other pairing methods"切到手动配对路径(部分 plug 自动配对不行,需手动)
|
||
postConnectSteps?: (driver: DeviceDriver) => Promise<void>; // 连接后专属流程(如 curtain 选模式+行程校验);提供则替代默认 navigateThroughWizard
|
||
holdMs?: number; // 覆盖按键时长
|
||
disclaimerMaxNext?: number; // 到"引导页(按按键处)"前需点几次 Next(过免责声明),默认 1
|
||
connectionKeywords?: string[];
|
||
wizardButtons?: string[];
|
||
}
|
||
|
||
/** 点"下一步/连接设备"类按钮。连接/引导页按钮文案因品类而异(Next / Connect Device / Connect Devices),依次尝试。返回是否点到。 */
|
||
async function tapNext(driver: DeviceDriver): Promise<boolean> {
|
||
if (driver.platform === 'android') {
|
||
// 兼容 Next 与 Connect Device(部分品类如 Safety Alarm/Find Card 引导页是 "Connect Device(s)" 非 Next)
|
||
for (const t of ['Next', 'Connect Device', 'Connect Devices', 'Connect device']) {
|
||
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`);
|
||
if (el) { await driver.tapElement(el); return true; }
|
||
}
|
||
return false;
|
||
}
|
||
// iOS:不同页的推进按钮名不同(免责声明="Next"、配对页="Connect device"、连接失败页="Retry"),依次尝试
|
||
for (const name of ['Next', 'Connect device', 'Connect Device', 'Retry', 'Continue', 'Done']) {
|
||
const el = await driver.findElementRaw('name', name).catch(() => null);
|
||
if (el) { await driver.tapElement(el); return true; }
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 从"输入设备名"页读取默认设备名(连接成功/失败后都会跳到该页)。
|
||
* 取输入框(Android EditText / iOS TextField)的当前文本 = 设备默认名。找不到返回 null。
|
||
*/
|
||
export async function captureDeviceNameFromInput(driver: DeviceDriver): Promise<string | null> {
|
||
const el = driver.platform === 'android'
|
||
? await driver.findElementRaw('class name', 'android.widget.EditText')
|
||
: (await driver.findElementRaw('class name', 'XCUIElementTypeTextField'))
|
||
|| (await driver.findElementRaw('class name', 'XCUIElementTypeTextView'));
|
||
if (!el) return null;
|
||
// iOS 用 value/label(不用 'text',iOS 不支持→WDA 返回对象→String 化成 "[object Object]");Android 用 text/value。
|
||
const attrs = driver.platform === 'android' ? ['text', 'value'] : ['value', 'label', 'name'];
|
||
let v: string | null = null;
|
||
for (const attr of attrs) {
|
||
const raw = await driver.getElementAttribute(el, attr).catch(() => null);
|
||
if (typeof raw === 'string' && raw.trim() && raw !== '[object Object]') { v = raw; break; }
|
||
}
|
||
const name = v ? v.trim() : '';
|
||
// 过滤占位符/提示文案
|
||
if (!name || /enter|name|请输入|名称|Wi-?Fi|password|密码/i.test(name)) return null;
|
||
return name;
|
||
}
|
||
|
||
/**
|
||
* 串口配对添加流程(meter / remote 等用继电器按物理键进配对的设备):
|
||
* 进添加页 → 选品类 → (有则过 Disclaimers)到引导页 → **继电器按键进配对** → 点 Next 连接 → 走完向导 → 首页校验。
|
||
*
|
||
* 通用兼容:没有 Disclaimers 的设备会直接到引导页(循环里检测不到 Disclaimer 即跳过)。
|
||
* 成功判据 = **设备出现在首页**(不靠成功页关键字硬判,避免文案不符导致误判失败);
|
||
* 成功后把设备名写入注册表(config/device.config 的 getDeviceName 会优先用它作功能页入口)。
|
||
*/
|
||
export async function addDeviceWithSerialPairing(
|
||
driver: DeviceDriver,
|
||
options: SerialPairingAddOptions
|
||
): Promise<boolean> {
|
||
const {
|
||
categoryName,
|
||
categoryScrollHint = 0,
|
||
deviceKeyword,
|
||
relayDevice,
|
||
registryCategory = relayDevice,
|
||
namePattern,
|
||
preferNamePattern = false,
|
||
beforePairing,
|
||
pairRetries = 3,
|
||
pairPollIters = 12,
|
||
repressOnRetry = true,
|
||
tapOtherPairing = false,
|
||
postConnectSteps,
|
||
holdMs,
|
||
disclaimerMaxNext = 1,
|
||
connectionKeywords,
|
||
wizardButtons = DEFAULT_WIZARD_BUTTONS,
|
||
} = options;
|
||
|
||
const addDevicePage = await navigateToAddPage(driver, 'Device');
|
||
if (!addDevicePage) { console.log('FAIL: cannot navigate to Add Device'); return false; }
|
||
|
||
const catSelected = await selectDeviceCategory(driver, categoryName, categoryScrollHint, !!options.categoryContains);
|
||
if (!catSelected) { console.log(`FAIL: cannot select category ${categoryName}`); return false; }
|
||
const __T0 = Date.now(); const __EL = () => `${Math.round((Date.now() - __T0) / 1000)}s`;
|
||
console.log(`[计时] 选品类完成 @${__EL()}`);
|
||
|
||
// 过免责声明到引导页(无 Disclaimers 的设备自动跳过)
|
||
for (let i = 0; i < disclaimerMaxNext; i++) {
|
||
const src = await driver.getSource();
|
||
if (!src.includes('Disclaimer')) break;
|
||
if (!(await tapNext(driver))) break;
|
||
await sleep(1500);
|
||
}
|
||
|
||
// 配对前额外步骤(如 outdoor meter 套件:先点 "Add Meter" 进计量器子流程)
|
||
if (beforePairing) { await beforePairing(driver); await sleep(2000); }
|
||
|
||
// 引导页:继电器按物理键进配对 → 点 Next 触发连接。
|
||
// 注意:有些设备(如 remote 凸+凹)的配对键是"切换式"——按一次进配对、再按一次退出。
|
||
// 这类设备传 repressOnRetry=false:只按一次,重试时仅重点 Next + 延长检测,绝不重按(重按会把它切出配对)。
|
||
const startedRe = namePattern ? new RegExp(namePattern) : null;
|
||
// 连接失败页标志(iOS "Failed to connect to your device" + Retry;curtain3 首连尤其易失败)
|
||
const FAIL_MARKERS = ['Failed to connect', 'Failed to Connect', 'Connection failed', 'Try Again', '连接失败', '无法连接'];
|
||
const pairingStarted = async (): Promise<boolean> => {
|
||
const src = await driver.getSource();
|
||
if (FAIL_MARKERS.some((k) => src.includes(k))) return false; // 失败页不算"已开始"(否则会跳过重试)
|
||
if (CONNECT_STARTED_KEYWORDS.some((k) => src.includes(k))) return true; // 进入连接/设置页
|
||
if (startedRe && startedRe.test(src)) return true; // 设备已被发现(如 "Remote 63")
|
||
return false;
|
||
};
|
||
let started = false;
|
||
let pressed = false;
|
||
for (let attempt = 0; attempt < pairRetries && !started; attempt++) {
|
||
// 第一次必按;之后是否重按取决于 repressOnRetry(切换式配对键不重按,只重点 Next)
|
||
if (!pressed || repressOnRetry) {
|
||
await enterPairingMode(relayDevice, holdMs ? { holdMs } : {}).catch((e) =>
|
||
console.log(`relay 配对按键失败(检查 64 路开发板接线/端口):${e.message}`)
|
||
);
|
||
pressed = true;
|
||
await sleep(800);
|
||
}
|
||
if (!(await tapNext(driver))) console.log('WARN: 引导页未找到 Next');
|
||
// 部分 plug 自动配对不触发 / iOS 弹"Add to HomeKit"页 → 点"其他配对方式/Other Pairing Methods"切到 SwitchBot 配网路径(opt-in)。
|
||
// ⚠️ 该按钮(HomeKit 页)在配对后 ~4s 才出现 → 轮询等它出现再点(否则 tapNext 后立即找会错过,且 "Connecting" 一出现就退循环再无机会)。
|
||
if (tapOtherPairing) {
|
||
const otherLabels = ['Other Pairing Methods', '其他配对方式', 'Other pairing methods', 'Other pairing method', 'Connect another way', 'Pair another way', '其他方式', '其他配对'];
|
||
for (let w = 0; w < 9; w++) {
|
||
let clicked = false;
|
||
for (const t of otherLabels) {
|
||
const el = driver.platform === 'ios'
|
||
? (await driver.findElementRaw('predicate string', `label CONTAINS "${t}" OR name CONTAINS "${t}"`).catch(() => null))
|
||
: (await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${t}")`).catch(() => null));
|
||
if (el) {
|
||
console.log(`点击"其他配对方式"(${t})切配网路径`); await driver.tapElement(el); await sleep(2000);
|
||
// 点后弹确认框(如"切换配对方式?/是否继续?")→ 点 Yes/确定 才真正切到 SwitchBot 配网路径
|
||
for (const y of ['Yes', '是', 'Confirm', 'Continue', 'OK', '确定', '继续']) {
|
||
const yb = driver.platform === 'ios'
|
||
? (await driver.findElementRaw('predicate string', `label == "${y}" OR name == "${y}"`).catch(() => null))
|
||
: (await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${y}")`).catch(() => null));
|
||
if (yb) { console.log(`确认弹框: ${y}`); await driver.tapElement(yb); await sleep(2500); break; }
|
||
}
|
||
clicked = true; break;
|
||
}
|
||
}
|
||
if (clicked) break;
|
||
await sleep(1500);
|
||
}
|
||
}
|
||
// 检测轮询:出现连接关键字/设备名即视为进配对成功。窗口 = pairPollIters×1.5s。
|
||
// remote 的 App BLE 检测延迟大且不稳(实测可达 16s+),需更长窗口(见 remote 用例 pairPollIters)。
|
||
for (let t = 0; t < pairPollIters && !started; t++) {
|
||
if (await pairingStarted()) { started = true; break; }
|
||
await driver.dismissPopupIfPresent(); // 配对/连接中途突然冒出的弹框,顺手关掉
|
||
await sleep(1500);
|
||
}
|
||
if (started) break;
|
||
console.log(`配对未触发/未发现设备(第 ${attempt + 1}/${pairRetries} 次)${repressOnRetry ? ',清弹框后重按继电器重试...' : ',仅重点 Next(切换式配对键不重按)...'}`);
|
||
await driver.dismissPopupIfPresent(); // 清可能的"连接失败"弹框,回到引导页再试
|
||
}
|
||
if (!started) console.log('WARN: 多次重试后仍未检测到连接开始,继续走后续(大概率失败)');
|
||
console.log(`[计时] 配对/连接检测完成(started=${started}) @${__EL()}`);
|
||
|
||
// 收尾等待(连上则多等让其完成);连接失败弹框主动关掉,避免残留挡住后续(尤其 curtain3)
|
||
await waitForConnection(driver, connectionKeywords, started ? 9000 : 3000);
|
||
await driver.dismissPopupIfPresent();
|
||
console.log(`[计时] waitForConnection 完成 @${__EL()}`);
|
||
|
||
// 连接成功(或失败)后通常跳到"输入设备名"页:字段里的默认名即设备名 → 最可靠的取名处
|
||
const capturedName = await captureDeviceNameFromInput(driver);
|
||
if (capturedName) console.log(`从"输入设备名"页捕获默认名: "${capturedName}"`);
|
||
|
||
// 连接后:有专属流程(如 curtain 选模式 + 行程校验)则走它,否则走默认向导
|
||
if (postConnectSteps) await postConnectSteps(driver);
|
||
else await navigateThroughWizard(driver, wizardButtons);
|
||
await driver.dismissPopupIfPresent();
|
||
console.log(`[计时] postConnect/配网+向导 完成 @${__EL()}`);
|
||
|
||
// 成功判据:输入页捕获到名字 或 设备出现在首页 → 记录实际设备名作为功能页入口
|
||
await ensureHomeTab(driver);
|
||
await scrollHomeToTopIOS(driver); // iOS 回最顶再下滑找(否则停在中间漏掉靠下的新设备卡,如 Hub Mini Matter)
|
||
await sleep(1200);
|
||
const candidates = [capturedName, deviceKeyword].filter(Boolean) as string[];
|
||
const re = namePattern ? new RegExp(namePattern) : null;
|
||
await driver.dismissPopupIfPresent(); // 添加完成后的"更新/评分/新功能"弹框,开头关一次即可(原来每轮关=12次多余 getSource,拖慢 verify ~20-30s)
|
||
for (let i = 0; i < 12; i++) { // 设备多时新设备常在列表底部,需充分下滑(iOS 尤其);命中即返回
|
||
if (i === 5) await driver.dismissPopupIfPresent(); // 中途补关一次(延迟弹出的评分/更新框)
|
||
const source = await driver.getSource();
|
||
// preferNamePattern:加时内联改名的设备(curtain/灯类),改名后名("Curtain Auto")**含**默认名子串("Curtain"),
|
||
// 若先走 candidates 的 source.includes 会松匹配存成通用名 → 优先用 namePattern 取真实(改名后)名回写。
|
||
if (preferNamePattern && re) {
|
||
const m = source.match(re);
|
||
if (m) { saveAddedDevice(registryCategory, m[0]); return true; }
|
||
}
|
||
for (const c of candidates) {
|
||
if (source.includes(c)) { saveAddedDevice(registryCategory, c); return true; }
|
||
}
|
||
if (re) {
|
||
const m = source.match(re);
|
||
if (m) { saveAddedDevice(registryCategory, m[0]); return true; } // 实际名(如 Meter 0B)
|
||
}
|
||
await driver.scrollDown(500);
|
||
await sleep(300);
|
||
}
|
||
// 成功 = 设备**真出现在首页**(上面 loop 已覆盖 capturedName/keyword/pattern 在首页的情况)。
|
||
// 注意:连接成功/失败都会经过"输入设备名"页,capturedName 有值≠添加成功 → 不能仅凭它判通过(否则配网失败也误判 PASS)。
|
||
if (capturedName) console.log(`WARN: 捕获到名字"${capturedName}"但首页未出现该设备 → 判为未添加(配网/连接可能失败)`);
|
||
return false;
|
||
}
|
||
|
||
// 首页设备卡容器 id(网格左/右列)。各设备类型 name 控件 id 不同(nameText / nameTextMeter…),
|
||
// 但卡片容器 leftCard/rightCard 一致 → 用它通用枚举/长按删除,与设备类型无关。
|
||
const HOME_CARD_IDS = [
|
||
'com.theswitchbot.switchbot:id/leftCard',
|
||
'com.theswitchbot.switchbot:id/rightCard',
|
||
];
|
||
|
||
/** 若处于多选模式(顶部 Finish),退出它,回到正常首页。 */
|
||
async function exitSelectMode(driver: DeviceDriver): Promise<void> {
|
||
const finish = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")');
|
||
if (finish) { await driver.tapElement(finish); await sleep(1500); }
|
||
}
|
||
|
||
/** 找首页第一张设备卡容器(滚动查找),返回 {el, name};没有返回 null。 */
|
||
async function firstDeviceCard(driver: DeviceDriver): Promise<{ el: string; name: string } | null> {
|
||
// 轻量恢复到干净首页(退残留向导 + 关弹框 + 回首页),不做冷重启(冷启动会让首页卡片晚渲染→漏删)
|
||
await exitSelectMode(driver);
|
||
for (const t of ['Return Home', 'Quit', 'Exit', 'Leave']) {
|
||
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`);
|
||
if (el) { await driver.tapElement(el); await sleep(1500); break; }
|
||
}
|
||
await driver.dismissPopupIfPresent();
|
||
await ensureHomeTab(driver);
|
||
await scrollHomeToTopIOS(driver); // iOS 回最顶再下滑找(否则漏掉靠下的新设备卡)
|
||
await sleep(1500); // 等首页渲染
|
||
// 充分轮询(~20s):设备卡渲染慢也能等到。优先卡片容器,兜底设备名控件(各型号 id 不同)
|
||
for (let s = 0; s < 12; s++) {
|
||
for (const id of HOME_CARD_IDS) {
|
||
const els = await driver.findElementsRaw('id', id);
|
||
if (els.length) {
|
||
const nameEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceIdMatches(".*:id/name.*")').catch(() => null);
|
||
const name = (nameEl && (await driver.getElementAttribute(nameEl, 'text').catch(() => ''))) || '(device)';
|
||
return { el: els[0], name };
|
||
}
|
||
}
|
||
const nameEls = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().resourceIdMatches(".*:id/name.*")').catch(() => []);
|
||
if (nameEls && nameEls.length) {
|
||
const name = (await driver.getElementAttribute(nameEls[0], 'text').catch(() => '')) || '(device)';
|
||
return { el: nameEls[0], name };
|
||
}
|
||
await driver.scrollDown(500);
|
||
await sleep(900);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 长按某设备卡 → 多选模式 → 底部 Delete → 确认(删除单个,与房间无关)。 */
|
||
/**
|
||
* 批量删除:长按首张卡 → 进多选模式 → 选中本屏其它卡(同房间一次性多选)→ 一次 Delete → 确认。
|
||
* 跨房间不能一起删(点跨房间卡会被忽略),其余轮次再处理。返回本批删除的设备名。
|
||
*/
|
||
async function deleteVisibleBatch(driver: DeviceDriver): Promise<string[]> {
|
||
const card = await firstDeviceCard(driver);
|
||
if (!card) return [];
|
||
const r0 = await driver.getElementRect(card.el);
|
||
const cx0 = Math.round(r0.x + r0.width / 2), cy0 = Math.round(r0.y + r0.height / 2);
|
||
|
||
await driver.longPress(cx0, cy0, 1.0);
|
||
await sleep(1000); // 等多选模式出现
|
||
|
||
// 是否仍处于"可删除"的有效多选(底部有 Delete)。跨房间多选会让 Delete 消失。
|
||
const hasDelete = () => driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
|
||
const exitSelect = async () => {
|
||
const finish = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")');
|
||
if (finish) { await driver.tapElement(finish); await sleep(800); }
|
||
};
|
||
|
||
// 长按未进入有效多选(无 Delete)→ 退出,交给外层下一轮/恢复
|
||
if (!(await hasDelete())) { await exitSelect(); return []; }
|
||
|
||
// 逐张点选本屏其它卡:点完校验 Delete 是否还在。
|
||
// 跨房间卡会让 Delete 消失 → 立即再点一次取消该卡(恢复有效选择),跳过它;
|
||
// 这样一批只删"同房间"设备,其它房间留给 removeAllDevices 外层循环。
|
||
const picked = [card.name];
|
||
const others: { cx: number; cy: number }[] = [];
|
||
for (const id of HOME_CARD_IDS) {
|
||
for (const el of await driver.findElementsRaw('id', id)) {
|
||
try {
|
||
const r = await driver.getElementRect(el);
|
||
const cx = Math.round(r.x + r.width / 2), cy = Math.round(r.y + r.height / 2);
|
||
if (Math.abs(cx - cx0) < 12 && Math.abs(cy - cy0) < 12) continue; // 跳过长按那张
|
||
others.push({ cx, cy });
|
||
} catch { /* 卡片可能已变,忽略 */ }
|
||
}
|
||
}
|
||
for (const { cx, cy } of others) {
|
||
await driver.tap(cx, cy);
|
||
await sleep(400);
|
||
if (await hasDelete()) {
|
||
picked.push('(device)'); // 同房间,保留选中
|
||
} else {
|
||
await driver.tap(cx, cy); // 跨房间→Delete消失,取消该卡恢复有效选择
|
||
await sleep(400);
|
||
}
|
||
}
|
||
|
||
// 点底部 Delete
|
||
const delBtn = await hasDelete();
|
||
if (!delBtn) { await exitSelect(); return []; }
|
||
await driver.tapElement(delBtn);
|
||
await sleep(900);
|
||
|
||
// 确认弹框(快速):Delete / OK / 删除 / Confirm
|
||
for (let retry = 0; retry < 3; retry++) {
|
||
const src = await driver.getSource();
|
||
if (/Cancel|取消|delete|删除/i.test(src)) {
|
||
let confirm: string | null = null;
|
||
for (const t of ['Delete', 'OK', '删除', 'Confirm']) {
|
||
confirm = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`);
|
||
if (confirm) break;
|
||
}
|
||
if (confirm) { await driver.tapElement(confirm); break; }
|
||
}
|
||
await sleep(500);
|
||
}
|
||
await sleep(1800); // 等删除生效 + 首页刷新
|
||
return picked;
|
||
}
|
||
|
||
/**
|
||
* 首页是否还有设备卡 —— 用于 reset 后校验"是否真的清空了"。
|
||
* 只看卡片容器(leftCard/rightCard),与设备类型无关。Android 专用;iOS 待补返回 false。
|
||
*/
|
||
export async function homepageHasDeviceCards(driver: DeviceDriver): Promise<boolean> {
|
||
if (driver.platform !== 'android') return false;
|
||
await ensureHomeTab(driver);
|
||
for (const id of HOME_CARD_IDS) {
|
||
const els = await driver.findElementsRaw('id', id);
|
||
if (els.length) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 清空当前平台账号下的**所有**设备 —— 必测执行前置,确保 connect 用例真正走添加流程。
|
||
* 通用按设备卡(nameText)枚举,逐个长按删除(跨房间不能一起删,逐个最稳),与设备名无关。
|
||
* 删完清空设备名注册表。返回已删除的设备名列表。
|
||
*/
|
||
export async function removeAllDevices(
|
||
driver: DeviceDriver,
|
||
opts: { max?: number } = {}
|
||
): Promise<string[]> {
|
||
const removed: string[] = [];
|
||
if (driver.platform === 'ios') {
|
||
// iOS:逐个删(最稳)。按设备名找卡 → 长按进多选 → 底部 Delete → 弹框 "Confirm"。循环到无设备。
|
||
const NAME_RE = /(Meter|Outdoor Meter|Remote|Curtain|Hub Mini|Hub 2|Hub|Bot|Plug|Lock|Keypad|Blind Tilt)\s+[0-9A-Za-z]{2}\b/;
|
||
const maxIos = opts.max ?? 50;
|
||
for (let round = 0; round < maxIos; round++) {
|
||
const fin = await driver.findElementRaw('name', 'Finish').catch(() => null);
|
||
if (fin) { await driver.tapElement(fin); await sleep(800); } // 退出残留多选
|
||
const src = await driver.getSource();
|
||
const m = src.match(NAME_RE);
|
||
if (!m) break; // 首页已无设备
|
||
const name = m[0];
|
||
const card = await driver.findElementRaw('predicate string', `name == "${name}" OR label == "${name}"`).catch(() => null);
|
||
if (!card) break;
|
||
const r = await driver.getElementRect(card);
|
||
await driver.longPress(Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2), 1.2);
|
||
await sleep(1500);
|
||
const del = await driver.findElementRaw('name', 'Delete').catch(() => null);
|
||
if (!del) { const f2 = await driver.findElementRaw('name', 'Finish').catch(() => null); if (f2) await driver.tapElement(f2); break; }
|
||
await driver.tapElement(del); await sleep(1200);
|
||
const confirm = await driver.findElementRaw('name', 'Confirm').catch(() => null); // 确认框 "Delete the selected items?" → Confirm
|
||
if (confirm) await driver.tapElement(confirm);
|
||
await sleep(2500);
|
||
removed.push(name);
|
||
console.log(`已删 ${name}`);
|
||
}
|
||
clearAddedDevices();
|
||
console.log(`removeAllDevices[ios] 完成,共删除 ${removed.length} 个: ${removed.join(', ') || '(无)'}`);
|
||
return removed;
|
||
}
|
||
if (driver.platform !== 'android') {
|
||
console.log('removeAllDevices: 该平台未实现');
|
||
return removed;
|
||
}
|
||
const max = opts.max ?? 50;
|
||
// 不在开头冷重启(会让首页卡片晚渲染→漏删);firstDeviceCard 内已做轻量恢复 + 充分轮询
|
||
for (let round = 0; round < max; round++) {
|
||
const batch = await deleteVisibleBatch(driver);
|
||
if (!batch.length) break; // 首页已无任何设备卡
|
||
removed.push(...batch);
|
||
console.log(`本批删除 ${batch.length} 个: ${batch.join(', ')}`);
|
||
}
|
||
clearAddedDevices();
|
||
console.log(`removeAllDevices 完成,共删除 ${removed.length} 个: ${removed.join(', ') || '(无)'}`);
|
||
return removed;
|
||
}
|
||
|
||
export async function removeDeviceFromAccount(driver: DeviceDriver, keyword: string): Promise<boolean> {
|
||
await ensureHomeTab(driver);
|
||
|
||
if (driver.platform === 'android') {
|
||
let botCard = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${keyword}")`);
|
||
if (!botCard) {
|
||
await driver.scrollDown(300);
|
||
await sleep(800);
|
||
botCard = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${keyword}")`);
|
||
}
|
||
if (!botCard) return false;
|
||
await driver.tapElement(botCard);
|
||
await sleep(2000);
|
||
|
||
// Scroll to find Delete button
|
||
let delEl: string | null = null;
|
||
for (let i = 0; i < 5; i++) {
|
||
delEl = await driver.findElementRaw('-android uiautomator',
|
||
'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text("Delete"))');
|
||
if (delEl) break;
|
||
await driver.scrollDown(300);
|
||
await sleep(800);
|
||
}
|
||
if (!delEl) { console.log('Delete button not found'); return false; }
|
||
await driver.tapElement(delEl);
|
||
await sleep(3000);
|
||
|
||
// Confirmation dialog
|
||
let confirmed = false;
|
||
for (let retry = 0; retry < 3; retry++) {
|
||
const src = await driver.getSource();
|
||
if (src.includes('Cancel') && src.includes('deleting this device')) {
|
||
const confirmEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")');
|
||
if (confirmEl) {
|
||
await driver.tapElement(confirmEl);
|
||
confirmed = true;
|
||
break;
|
||
}
|
||
}
|
||
await sleep(1000);
|
||
}
|
||
if (!confirmed) { console.log('Delete confirmation not found'); return false; }
|
||
await sleep(3000);
|
||
|
||
await driver.goBackToHomepage();
|
||
await sleep(1000);
|
||
const homeSource = await driver.getSource();
|
||
return !homeSource.includes(keyword);
|
||
}
|
||
|
||
// iOS
|
||
let botCard = await driver.findElementRaw('predicate string', `name CONTAINS "${keyword}"`);
|
||
if (!botCard) {
|
||
await driver.scrollDown(300);
|
||
await sleep(800);
|
||
botCard = await driver.findElementRaw('predicate string', `name CONTAINS "${keyword}"`);
|
||
}
|
||
if (!botCard) return false;
|
||
await driver.tapElement(botCard);
|
||
await sleep(1500);
|
||
|
||
const settingsEl = await waitForElement(driver, 'name', 'Settings', 5000);
|
||
if (!settingsEl) return false;
|
||
await driver.tapElement(settingsEl);
|
||
await sleep(2000);
|
||
|
||
let delEl: string | null = null;
|
||
for (let i = 0; i < 3; i++) {
|
||
delEl = await driver.findElementRaw('predicate string', 'name == "Delete" AND visible == true');
|
||
if (delEl) break;
|
||
await driver.scrollDown(300);
|
||
await sleep(800);
|
||
}
|
||
if (!delEl) { console.log('Delete button not found'); return false; }
|
||
await driver.tapElement(delEl);
|
||
await sleep(2000);
|
||
|
||
const okEl = await waitForElement(driver, 'name', 'OK', 5000);
|
||
if (!okEl) { console.log('Delete confirmation not found'); return false; }
|
||
await driver.tapElement(okEl);
|
||
await sleep(3000);
|
||
|
||
await driver.goBackToHomepage();
|
||
await sleep(1000);
|
||
const homeSource = await driver.getSource();
|
||
return !homeSource.includes(keyword);
|
||
}
|