AI_UIAutomation/utils/common/navigation.helper.ts

277 lines
13 KiB
TypeScript

import { DeviceDriver } from '../../drivers/types';
import { sleep, waitForElement, waitForSource } from './element.helper';
import { execSync } from 'child_process';
import { APP_CONFIG } from '../../config/app.config';
/**
* 强制重启 Android App(force-stop + 重新拉起)—— 清掉卡住的弹框/崩溃页/残留向导,让首页"+"恢复可用。
* 夜间 soak 实测:curtain3 连接失败弹框未处理会一直挡住"+",导致后续整轮导航失败。
*/
export async function restartAndroidApp(driver: DeviceDriver): Promise<void> {
const pkg = APP_CONFIG.android.appPackage;
try {
execSync(`adb shell am force-stop ${pkg}`, { stdio: 'ignore' });
await sleep(1500);
await driver.activateApp(pkg).catch(() => {
try { execSync(`adb shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, { stdio: 'ignore' }); } catch { /* ignore */ }
});
await sleep(6000);
await driver.dismissPopupIfPresent();
await ensureHomeTab(driver);
await driver.dismissPopupIfPresent();
} catch (e: any) {
console.log('restartAndroidApp 异常:', e.message);
}
}
export async function ensureHomeTab(driver: DeviceDriver): Promise<void> {
await driver.goBackToHomepage();
await sleep(600);
if (driver.platform === 'android') {
try {
const homeTab = await driver.findElementRaw('accessibility id', 'Home');
if (homeTab) { await driver.tapElement(homeTab); await sleep(600); }
} catch { /* element may be stale after navigation */ }
} else {
const homeTab = await driver.findElementRaw('predicate string', 'name BEGINSWITH "HomeScene"');
if (homeTab) { await driver.tapElement(homeTab); await sleep(1000); }
}
}
/** iOS 首页**快滑回最顶**(慢拖在 RN 首页回弹不滚→必须 0.1s 快甩;上→下方向=列表回顶)。
* ⚠️ **只用于"查找/校验设备卡"路径**(findDeviceNameOnHomepage / 添加后首页校验),让其从顶部开始下滑找卡,与 Android 一致。
* **绝不要放进 ensureHomeTab**:navigateToAddPage 也用 ensureHomeTab 找 'Add' 按钮,回顶快滑会把它拖垮(实测 keypad 反复 restart+找不到 Add)。
* Android 点 Home tab 已会回顶,无需此步。 */
export async function scrollHomeToTopIOS(driver: DeviceDriver): Promise<void> {
if (driver.platform !== 'ios') return;
// 回顶 = 多次简单快速上滑(driver.scrollUp 是 0.1s 快速甩动,和下滑同款,不是长按拖动)。
for (let i = 0; i < 6; i++) { await driver.scrollUp(500); await sleep(150); }
}
/**
* 等首页渲染就绪(轮询 isOnHomepage)。SOAK 连跑时每个用例新建会话拉起 App,**首个动作常在 App 还在启动中**
* (splash/加载页)→ 找不到卡片直接失败(昨晚百叶帘失败截图=app启动中)。先等首页真出来再操作。
*/
export async function waitForHomeReady(driver: DeviceDriver, timeoutMs = 25000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try { if (await driver.isOnHomepage()) return true; } catch { /* 启动中 getSource 可能失败 */ }
await driver.dismissPopupIfPresent().catch(() => {});
await sleep(1200);
}
return false;
}
export async function navigateToAddPage(
driver: DeviceDriver,
type: 'Device' | 'Scene'
): Promise<boolean> {
if (driver.platform === 'android') {
const addBtoId = 'com.theswitchbot.switchbot:id/addBto';
const menuText = type === 'Device' ? 'Add Device' : 'Add Scene';
// 最多 3 次。快路:直接找"+";在则我们已在可加设备的首页,跳过 dismiss/ensureHomeTab 恢复(省 ~6s)。
// 找不到"+"才恢复(首轮轻量:dismiss+ensureHomeTab;再不行才重启 App 清残留弹框)。
for (let attempt = 0; attempt < 3; attempt++) {
let addBtn = await driver.findElementRaw('id', addBtoId);
if (!addBtn) {
await driver.dismissPopupIfPresent();
await ensureHomeTab(driver);
await driver.dismissPopupIfPresent();
if (attempt > 0) await restartAndroidApp(driver); // 轻量恢复无效 → 重启清残留弹框
addBtn = await driver.findElementRaw('id', addBtoId);
}
if (addBtn) {
await driver.tapElement(addBtn);
// 轮询菜单出现(替代固定 sleep1000),出现即走
let menuEl: string | null = null;
for (let i = 0; i < 8 && !menuEl; i++) {
menuEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${menuText}")`);
if (!menuEl) await sleep(250);
}
if (menuEl) {
await driver.tapElement(menuEl);
// 轮询目标页加载(替代固定 sleep1500)
for (let i = 0; i < 8; i++) {
const src = await driver.getSource();
const ok = type === 'Device' ? (src.includes('Bot') || src.includes('Add Device')) : src.includes('Create Scene');
if (ok) return true;
await sleep(300);
}
}
}
console.log(`navigateToAddPage 第${attempt + 1}次未成(addBto/菜单缺失或残留弹框)...`);
}
console.log('FAIL: cannot navigate to Add Device(重试后仍失败)');
return false;
}
// iOS - 先稳健拿到 "Add" 按钮:找不到说明不在首页(被上个用例的添加弹层/页面占住)→ dismiss + 回 Home tab 重试;
// 仍不行则**重启 App 硬复位**(终极兜底,保证上个用例的残留态不级联污染本用例)。
let addBtn = await driver.findElementRaw('name', 'Add');
for (let attempt = 0; attempt < 3 && !addBtn; attempt++) {
// ① 轻量恢复:dismiss + 回首页
await driver.dismissPopupIfPresent();
await ensureHomeTab(driver); // goBackToHomepage + 点 HomeScene tab
await driver.dismissPopupIfPresent();
await sleep(600);
addBtn = await driver.findElementRaw('name', 'Add');
if (addBtn) break;
// ② 轻量恢复无效(上个用例失败卡在配网页/向导)→ 立即重启 App 硬复位(不再等下一 attempt)
if (typeof (driver as any).restartApp === 'function') {
console.log(`navigateToAddPage(iOS) 第${attempt + 1}次轻量恢复无效 → 重启 App 硬复位`);
await (driver as any).restartApp();
await ensureHomeTab(driver);
await sleep(600);
addBtn = await driver.findElementRaw('name', 'Add');
}
}
if (!addBtn) { console.log('FAIL: Add button not found'); return false; }
if (type === 'Scene') {
await driver.tapElement(addBtn);
await sleep(1500);
await driver.dismissPopupIfPresent();
const addSceneEl = await driver.findElementRaw('name', 'Add Scene');
if (!addSceneEl) { console.log('FAIL: no Add Scene'); return false; }
const rect = await driver.getElementRect(addSceneEl);
await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2);
const found = await waitForElement(driver, 'predicate string', 'name CONTAINS "Create Scene"', 8000);
if (!found) { console.log('FAIL: Create Scene not loaded'); return false; }
await driver.dismissPopupIfPresent();
return true;
}
// iOS - Add Device
const addRect = await driver.getElementRect(addBtn);
for (let attempt = 0; attempt < 2; attempt++) {
await driver.tap(addRect.x + addRect.width / 2, addRect.y + addRect.height / 2);
await sleep(1500);
const addDevice = await driver.findElementRaw('name', 'Add Device');
if (addDevice) {
const rect = await driver.getElementRect(addDevice);
await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 2);
return await waitForSource(driver, 'Add Device', 5000);
}
}
console.log('FAIL: no Add Device in menu');
return false;
}
export async function navigateToManageScenes(driver: DeviceDriver): Promise<boolean> {
if (driver.platform === 'android') {
await ensureHomeTab(driver);
const moreBtn = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/moreBto');
if (!moreBtn) { console.log('FAIL: moreBto not found'); return false; }
await driver.tapElement(moreBtn);
await sleep(1500);
const manageEl = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Manage Scenes")');
if (!manageEl) { console.log('FAIL: no Manage Scenes'); return false; }
await driver.tapElement(manageEl);
await sleep(2000);
return true;
}
// iOS
await driver.tap(347, 48);
await sleep(1500);
const manageEl = await driver.findElementRaw('name', 'Manage Scenes');
if (!manageEl) { console.log('FAIL: no Manage Scenes'); return false; }
await driver.tapElement(manageEl);
await sleep(2000);
return true;
}
export async function navigateThroughWizard(
driver: DeviceDriver,
buttonNames = ['Use now', 'Start Using', 'Done', 'Save', 'Got it', 'OK', 'Skip', 'Next'],
maxSteps = 12
): Promise<boolean> {
if (driver.platform === 'android') {
for (let i = 0; i < maxSteps; i++) {
const src = await driver.getSource(); // 每轮只取一次整页,避免重复 getSource
// 到首页:有底部导航/首页列表,且无添加向导标记
const onWizard = /Add (Bot|Meter|Curtain|Remote|Outdoor|Lock|Plug|Hub|Keypad|Fan|Light|Sensor|Device)|Added successfully|Install|Initial Setup|Select Mode|Disclaimers|Calibrat/i.test(src);
const onHome = src.includes('homeRecyclerView') || src.includes('addBto') ||
src.includes('content-desc="Home"') || src.includes('Automations') || src.includes('Add a Device');
if (!onWizard && onHome) return true;
// 从已取的 src 判断当前页存在哪个向导按钮,只查这一个(省去逐个 7 次查询)
const btn = buttonNames.find(b => src.includes(`text="${b}"`));
if (btn) {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${btn}")`);
if (el) { await driver.tapElement(el); await sleep(1800); continue; } // 放慢:安装指引/测试验证页渲染较慢
}
// 没有可点按钮:可能已完成或加载中,短等再判
await sleep(1000);
}
return await driver.isOnHomepage();
}
// iOS
for (let i = 0; i < maxSteps; i++) {
const source = await driver.getSource();
if (source.includes('XCUIElementTypeTabBar') && !source.includes('Add Bot')) return true;
let tapped = false;
for (const btn of buttonNames) {
// iOS 按钮常 name 为空、只有 label(如完成页的 "OK"/"Done")→ name 精确漏点;再试 label/name 精确 predicate。
const el = await driver.findElementRaw('name', btn).catch(() => null)
|| await driver.findElementRaw('predicate string', `name == "${btn}" OR label == "${btn}"`).catch(() => null);
if (el) { await driver.tapElement(el); await sleep(2000); tapped = true; break; }
}
if (!tapped) break;
}
return true;
}
/**
* 创建场景/自动化点 Save 后会弹"是否立即执行一次/应用"确认框 → 点 **Cancel** 关闭。
* Cancel 只是不立即执行,场景/自动化本身已保存。轮询等弹框出现再点(Save 后弹框有渲染延迟)。两端通用。
*/
export async function dismissSaveConfirmCancel(driver: DeviceDriver): Promise<boolean> {
for (let i = 0; i < 6; i++) {
const el = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Cancel")').catch(() => null)
: (await driver.findElementRaw('name', 'Cancel').catch(() => null))
|| (await driver.findElementRaw('predicate string', 'name == "Cancel" OR label == "Cancel"').catch(() => null));
if (el) { await driver.tapElement(el); await sleep(1200); return true; }
await sleep(700);
}
return false;
}
/**
* 关闭首次进入页面的引导浮层(coach-mark)。出现场景:首次进 创建自动化 / 创建场景 / 家庭管理 等页面,
* 会弹"Add what will trigger... [Next]" → "Advanced Options [Got it]" 这类教程浮层,拦截点击。
* 按引导标记依次点 Next / Got it 关掉(最多几步)。无引导则立即返回。
*/
export async function dismissGuideOverlay(driver: DeviceDriver): Promise<void> {
// 引导专用安全按钮:只在引导/coach-mark 上出现,单独可点(无引导文字标记也关,如保存后收尾的 "Got it")。
const SAFE = ['Got it', 'Got It', 'I Know', 'I know', 'Skip', '知道了', '我知道了', '跳过'];
// 引导文字标记:出现时才允许点「Next/下一步」(避免在正常页误推进)。
const GUIDE = ['trigger this Automation', 'Add what will', 'Advanced Options', 'will trigger',
'Swipe to', 'Tap to', 'Tap anywhere', 'tutorial', 'Step 1', 'Step 2', '引导', '教程'];
const NAV = ['Next', '下一步'];
let emptyReads = 0;
for (let i = 0; i < 12; i++) {
const src = await driver.getSource();
let tapped = false;
// 1) 先点安全引导按钮(Got it/Skip/...)
for (const b of SAFE) {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${b}")`);
if (el) { await driver.tapElement(el); tapped = true; break; }
}
// 2) 仅当确有引导标记时才点 Next/下一步(分步引导)
if (!tapped && GUIDE.some((g) => src.includes(g))) {
for (const b of NAV) {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${b}")`);
if (el) { await driver.tapElement(el); tapped = true; break; }
}
}
// 多步 coach-mark:点完一张后下一张("第二个 Got it")可能稍后才渲染 → 容忍一次空读再退出。
if (tapped) { emptyReads = 0; await sleep(1200); }
else { if (++emptyReads >= 2) break; await sleep(900); }
}
}