514 lines
29 KiB
TypeScript
514 lines
29 KiB
TypeScript
/**
|
|
* 平台账号 helper —— 登录 / 登出。Android 实测流程(2026-06):
|
|
* 登出: Profile tab → "Sign Out" → 确认框 "OK"
|
|
* 登录: 已登出 landing 顶部 "Sign in" → 表单(2 个 EditText:邮箱/密码)→ 填 → "Sign in"
|
|
* 表单上还有 "Sign up"(注册)、"Forgot Password"(忘记密码)入口。
|
|
*
|
|
* 主账号凭据走 config/account.config(分平台,env 可覆盖)。
|
|
* 注册/忘记密码/注销用临时邮箱(account-code.helper)。
|
|
*/
|
|
import { DeviceDriver } from '../../drivers/types';
|
|
import { AccountCredentials, getAccountCredentials } from '../../config/account.config';
|
|
import { sleep } from './element.helper';
|
|
|
|
async function tapText(driver: DeviceDriver, t: string): Promise<boolean> {
|
|
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); return true; }
|
|
return false;
|
|
}
|
|
|
|
async function tapId(driver: DeviceDriver, id: string): Promise<boolean> {
|
|
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().resourceId("com.theswitchbot.switchbot:id/${id}")`);
|
|
if (el) { await driver.tapElement(el); return true; }
|
|
return false;
|
|
}
|
|
|
|
async function sourceHas(driver: DeviceDriver, kw: string): Promise<boolean> {
|
|
return (await driver.getSource()).includes(kw);
|
|
}
|
|
|
|
/** 轮询等待某文本出现(应对 Loading 遮罩/页面过渡),最多 timeoutMs。 */
|
|
async function waitForText(driver: DeviceDriver, kw: string, timeoutMs = 8000): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await sourceHas(driver, kw)) return true;
|
|
await sleep(800);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** 进入 Profile tab(底部导航 content-desc="Profile")。 */
|
|
export async function goToProfile(driver: DeviceDriver): Promise<void> {
|
|
// Profile 是底部 tab,仅在主页(底部导航可见)时点得到。控制/场景等子页残留时直接找 Profile 会落空,
|
|
// 导致登录态误判 → signInWithEmail 找不到表单(EditText=0)。故登录态子页先复位主页。
|
|
// 但登出态/登录相关页(landing/表单)绝不能 goBack(BACK 会退出 App → 黑屏),此时跳过复位。
|
|
const src0 = await driver.getSource().catch(() => '');
|
|
const authOrLanding = /Forgot Password|Sign ?up|Create Account|Log ?in|Welcome|Continue with|Sign ?in/i.test(src0)
|
|
&& !/Sign Out|Manage Accounts/.test(src0);
|
|
if (!authOrLanding) {
|
|
await driver.goBackToHomepage().catch(() => {});
|
|
await sleep(700);
|
|
await driver.dismissPopupIfPresent().catch(() => {});
|
|
}
|
|
const prof = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Profile")');
|
|
if (prof) { await driver.tapElement(prof); await sleep(2000); }
|
|
// 清 Profile 页上覆盖的 Attention 框(安全等级低提示),否则挡住 Sign Out / 后续操作(EU 低安全账号登录后常驻)
|
|
await dismissEmailHintDialog(driver);
|
|
}
|
|
|
|
/** 是否已登录:Profile 出现 Sign Out/Manage Accounts。注册/登录后常有 "Loading..." 遮罩致 source 暂空,需等其结算。 */
|
|
export async function isLoggedIn(driver: DeviceDriver): Promise<boolean> {
|
|
for (let i = 0; i < 10; i++) {
|
|
await driver.dismissPopupIfPresent().catch(() => {});
|
|
let src = await driver.getSource();
|
|
// 已在 Profile(登录态)
|
|
if (src.includes('Sign Out') || src.includes('Manage Accounts')) return true;
|
|
// 在登录表单/landing(登出态)
|
|
if (src.includes('Forgot Password') || (src.includes('Sign in') && src.includes('Sign up'))) return false;
|
|
// Loading 遮罩 / 空页 / 过渡:等一下重试,不急着导航
|
|
if (src.trim().length < 80 || src.includes('Loading') || src.includes('加载')) { await sleep(2500); continue; }
|
|
// 其它页(如首页):去 Profile 确认
|
|
await goToProfile(driver);
|
|
src = await driver.getSource();
|
|
if (src.includes('Sign Out') || src.includes('Manage Accounts')) return true;
|
|
await sleep(2000);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** 登出:Profile → Sign Out → OK 确认。已登出则直接返回。 */
|
|
export async function signOut(driver: DeviceDriver): Promise<void> {
|
|
await goToProfile(driver);
|
|
// iOS:"Sign Out" 在 Profile 页**底部**,需先滚到底让按钮进可视区再点。否则 sourceHas 虽能在树里看到文字(离屏元素也在树),
|
|
// 但 tapText 点的是屏幕外坐标→没真正登出却误判"已登出"(实测:登出后页面无 "Sign in"、断言失败)。
|
|
if (driver.platform === 'ios') {
|
|
const sz = await driver.getWindowSize().catch(() => ({ width: 390, height: 844 }));
|
|
const cx = Math.round(sz.width / 2);
|
|
// 实测向下滑**一次**即可露出底部 "Sign Out"(原 6 次过多)。
|
|
await driver.swipe(cx, Math.round(sz.height * 0.8), cx, Math.round(sz.height * 0.25), 0.1); await sleep(500);
|
|
}
|
|
if (!(await sourceHas(driver, 'Sign Out'))) { console.log('已是登出态,跳过 Sign Out'); return; }
|
|
await tapText(driver, 'Sign Out');
|
|
await sleep(1500);
|
|
// 确认框:Are you sure you want to sign out? → OK(勿点 Cancel)
|
|
await tapText(driver, 'OK');
|
|
await sleep(4000);
|
|
console.log('已登出');
|
|
}
|
|
|
|
/**
|
|
* 邮箱登录。从任意页进入:先到 Profile,点登出态 landing 的 "Sign in" 进表单,填邮箱/密码,提交。
|
|
* @param creds 缺省用 config/account.config 的主账号
|
|
*/
|
|
export async function signInWithEmail(driver: DeviceDriver, creds?: AccountCredentials): Promise<void> {
|
|
const { email, password } = creds || getAccountCredentials();
|
|
await goToLoginForm(driver); // 到登录表单(等 Forgot Password 出现)
|
|
|
|
if (driver.platform === 'ios') {
|
|
// iOS:邮箱=XCUIElementTypeTextField,密码=XCUIElementTypeSecureTextField(Android 的 EditText className 在此不适用)
|
|
const TF = 'type == "XCUIElementTypeTextField"';
|
|
const SF = 'type == "XCUIElementTypeSecureTextField"';
|
|
let emailEl = await driver.findElementRaw('predicate string', TF).catch(() => null);
|
|
for (let i = 0; i < 8 && !emailEl; i++) { await sleep(1000); emailEl = await driver.findElementRaw('predicate string', TF).catch(() => null); }
|
|
if (!emailEl) throw new Error('iOS 登录表单未找到邮箱输入框(TextField)');
|
|
await driver.tapElement(emailEl).catch(() => {}); await sleep(300);
|
|
await driver.clearText(emailEl).catch(() => {});
|
|
await driver.typeText(emailEl, email); await sleep(400);
|
|
const pwdEl = await driver.findElementRaw('predicate string', SF).catch(() => null);
|
|
if (!pwdEl) throw new Error('iOS 登录表单未找到密码输入框(SecureTextField)');
|
|
await driver.tapElement(pwdEl).catch(() => {}); await sleep(300);
|
|
await driver.typeText(pwdEl, password); await sleep(400);
|
|
// 收键盘:iOS 点键盘 Done/Return(绝不 goBack——会退出表单)
|
|
for (const k of ['Done', 'Return', 'Go', 'Join', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
|
|
await sleep(500);
|
|
} else {
|
|
// 表单:2 个 EditText(邮箱[0]、密码[1])。Loading/过渡可能致暂时取不到 → 重试
|
|
let eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
|
|
for (let i = 0; i < 8 && eds.length < 2; i++) {
|
|
await sleep(1000);
|
|
eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
|
|
}
|
|
if (eds.length < 2) throw new Error(`登录表单未找到邮箱/密码输入框(EditText=${eds.length})`);
|
|
await driver.clearText(eds[0]).catch(() => {});
|
|
await driver.typeText(eds[0], email);
|
|
await sleep(400);
|
|
await driver.typeText(eds[1], password);
|
|
await sleep(400);
|
|
try { await driver.goBack(); } catch { /* 收起键盘 */ }
|
|
await sleep(500);
|
|
}
|
|
|
|
// 提交(表单的 "Sign in" 按钮)
|
|
await tapText(driver, 'Sign in');
|
|
await sleep(8000);
|
|
// 登录后可能弹 Attention 框(欧区区域提示 / 安全等级低)→ 有则点 Cancel 关掉,避免污染下个用例
|
|
await dismissEmailHintDialog(driver);
|
|
console.log(`已登录: ${email}`);
|
|
}
|
|
|
|
/** 从已登出态进入 Sign up(注册)页:landing → "Sign in"(进表单)→ "Sign up"(展开注册区)。已登录则先登出。 */
|
|
export async function goToSignUpPage(driver: DeviceDriver): Promise<void> {
|
|
await goToProfile(driver);
|
|
if (await sourceHas(driver, 'Sign Out')) { await signOut(driver); await goToProfile(driver); }
|
|
// landing(无 Forgot Password)→ 点 Sign in 进登录表单
|
|
if (!(await sourceHas(driver, 'Forgot Password'))) {
|
|
await tapText(driver, 'Sign in');
|
|
await waitForText(driver, 'Forgot Password', 8000);
|
|
}
|
|
// 登录表单 → 点 Sign up 展开注册区(出现 Enter email address)
|
|
for (let i = 0; i < 4 && !(await sourceHas(driver, 'Enter email address')); i++) {
|
|
await tapText(driver, 'Sign up');
|
|
await waitForText(driver, 'Enter email address', 4000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 注册新账号(临时邮箱)。Android 实测流程(2026-06):
|
|
* Sign up 页 → 填邮箱 → 勾 3 个协议(cbAgreement/llAdult/cbSubEmail)→ Get Verification Code
|
|
* → (Terms 弹框 Agree)→ "Enter verification code" 页(单框)输入码 → 设密码页(2 框)→ Confirm。
|
|
* 注:必须勾全 3 个协议,否则 Get Verification Code 不发码。验证码框是单个 EditText(非 6 格)。
|
|
* @returns 新账号 { email, password }
|
|
*/
|
|
export async function registerAccount(
|
|
driver: DeviceDriver,
|
|
opts: { password?: string } = {}
|
|
): Promise<{ email: string; password: string; token: string }> {
|
|
const { createTempMailbox, getVerificationCode } = await import('./account-code.helper');
|
|
const password = opts.password || 'Test1234a';
|
|
const box = await createTempMailbox();
|
|
|
|
await goToSignUpPage(driver);
|
|
|
|
// 填注册邮箱(hint=Enter email address)+ 勾全 3 协议(缺一不发码)
|
|
if (driver.platform === 'ios') {
|
|
// iOS:邮箱框=TextField;3 协议勾选框**无标签**,在各协议文字**左侧**(点文字左 ~x22)。键盘用 Done 收(绝不 goBack)。
|
|
const emailEl = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null);
|
|
if (!emailEl) throw new Error('iOS Sign up 页未找到邮箱输入框(TextField)');
|
|
await driver.tapElement(emailEl).catch(() => {}); await sleep(300);
|
|
await driver.clearText(emailEl).catch(() => {});
|
|
await driver.typeText(emailEl, box.address); await sleep(400);
|
|
for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
|
|
await sleep(500);
|
|
for (const kw of ['I agree to the', 'I am over 18', 'Sign me up']) {
|
|
const el = await driver.findElementRaw('predicate string', `label CONTAINS "${kw}" OR name CONTAINS "${kw}"`).catch(() => null);
|
|
if (el) { const r = await driver.getElementRect(el).catch(() => null); if (r) { await driver.tap(Math.max(22, r.x - 22), Math.round(r.y + r.height / 2)); await sleep(300); } }
|
|
else console.log(`WARN: iOS 未找到协议行 "${kw}"`);
|
|
}
|
|
} else {
|
|
const emailEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Enter email")');
|
|
if (!emailEd) throw new Error('Sign up 页未找到邮箱输入框');
|
|
await driver.clearText(emailEd).catch(() => {});
|
|
await driver.typeText(emailEd, box.address);
|
|
await sleep(400);
|
|
try { await driver.goBack(); } catch { /* 收键盘 */ }
|
|
// 勾全 3 个协议(缺一不发码)
|
|
for (const id of ['cbAgreement', 'llAdult', 'cbSubEmail']) {
|
|
const ok = await tapId(driver, id);
|
|
if (!ok) console.log(`WARN: 未勾到协议 ${id}`);
|
|
await sleep(300);
|
|
}
|
|
}
|
|
|
|
// 发码 + 同意条款弹框
|
|
await tapText(driver, 'Get Verification Code');
|
|
await sleep(2000);
|
|
await tapText(driver, 'Agree'); // 首次会弹 Terms 弹框,点 Agree;无则忽略
|
|
await sleep(2500);
|
|
|
|
// 取码 + 输入(单框 editText)
|
|
const code = await getVerificationCode(box.token, { timeoutMs: 60000 });
|
|
console.log(`注册 ${box.address} 验证码=${code}`);
|
|
const codeEd = driver.platform === 'ios'
|
|
? await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null)
|
|
: (await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")')
|
|
|| (await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'))[0]);
|
|
if (!codeEd) throw new Error('验证码输入框未找到');
|
|
await driver.tapElement(codeEd).catch(() => {});
|
|
await driver.typeText(codeEd, code);
|
|
await sleep(2000);
|
|
if (driver.platform === 'ios') { for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
|
|
else { try { await driver.goBack(); } catch { /* 收键盘 */ } }
|
|
|
|
// 验证码正确后通常自动进设密码页;若有推进按钮先点
|
|
for (const b of ['Next', 'Confirm', 'Continue', '下一步']) {
|
|
if (await sourceHas(driver, b)) { await tapText(driver, b); await sleep(2000); break; }
|
|
}
|
|
|
|
// 设密码页:新密码 / 确认密码 → Confirm
|
|
await sleep(1500);
|
|
if (driver.platform === 'ios') {
|
|
// iOS:2 个 SecureTextField(新密码/确认密码)
|
|
let sfs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => [] as string[]);
|
|
for (let i = 0; i < 6 && sfs.length < 2; i++) { await sleep(1000); sfs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => [] as string[]); }
|
|
if (sfs.length < 2) throw new Error(`iOS 设密码页 SecureTextField <2(实际 ${sfs.length})`);
|
|
await driver.tapElement(sfs[0]).catch(() => {}); await driver.typeText(sfs[0], password); await sleep(300);
|
|
await driver.tapElement(sfs[1]).catch(() => {}); await driver.typeText(sfs[1], password); await sleep(300);
|
|
for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
|
|
} else {
|
|
const pwds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
|
|
if (pwds.length < 2) throw new Error(`设密码页输入框 <2(实际 ${pwds.length}),需查看该页结构`);
|
|
await driver.typeText(pwds[0], password);
|
|
await sleep(300);
|
|
await driver.typeText(pwds[1], password);
|
|
await sleep(300);
|
|
try { await driver.goBack(); } catch { /* 收键盘 */ }
|
|
}
|
|
if (driver.platform === 'ios') {
|
|
// iOS 设密码页**保存按钮是 "OK"**(非 Confirm),点它才进"登录完成"页。显式重试点 OK,
|
|
// 不走下面通用循环——循环里 'Done' 在 'OK' 之前,若键盘未收会先点到键盘 Done 而 break、轮不到 OK。
|
|
let okTapped = false;
|
|
for (let i = 0; i < 6 && !okTapped; i++) { if (await tapText(driver, 'OK')) { okTapped = true; } else await sleep(1000); }
|
|
if (!okTapped) console.log('WARN: iOS 设密码页未点到 OK 保存按钮');
|
|
} else {
|
|
for (const b of ['Confirm', 'Done', 'OK', 'Sign up', 'Submit', '确定', '完成']) {
|
|
if (await tapText(driver, b)) break;
|
|
}
|
|
}
|
|
await sleep(6000);
|
|
await driver.dismissPopupIfPresent();
|
|
// 等注册后的 "Loading..." 遮罩结算到稳定登录态(Profile 有 Sign Out / 或到首页)
|
|
for (let i = 0; i < 8; i++) {
|
|
const src = await driver.getSource();
|
|
if (src.includes('Sign Out') || src.includes('Manage Accounts') || src.includes('Add a Device') || src.includes('content-desc="Home"')) break;
|
|
await driver.dismissPopupIfPresent().catch(() => {});
|
|
await sleep(2500);
|
|
}
|
|
console.log(`注册完成: ${box.address} / ${password}`);
|
|
return { email: box.address, password, token: box.token };
|
|
}
|
|
|
|
/** 进入登录表单(已登录则先登出)。等待表单出现(Forgot Password)。 */
|
|
async function goToLoginForm(driver: DeviceDriver): Promise<void> {
|
|
await goToProfile(driver);
|
|
if (await sourceHas(driver, 'Sign Out')) { await signOut(driver); await goToProfile(driver); }
|
|
// 失败注册/已存邮箱会弹 "Attention: Would you like to sign in with this email address?" → Cancel 回手动表单
|
|
await dismissEmailHintDialog(driver);
|
|
if (!(await sourceHas(driver, 'Forgot Password'))) {
|
|
await tapText(driver, 'Sign in');
|
|
await sleep(800);
|
|
await dismissEmailHintDialog(driver);
|
|
await waitForText(driver, 'Forgot Password', 8000);
|
|
}
|
|
}
|
|
|
|
/** 关闭登录前后的 "Attention" 提示弹框(统一点 Cancel,避免挡住表单)。覆盖:
|
|
* ① "Would you like to sign in with this email address?"(已存邮箱/失败注册残留)
|
|
* ② "Your account's security level is low ... link another login method"(安全等级低提示,EU/低安全账号登录后弹) */
|
|
async function dismissEmailHintDialog(driver: DeviceDriver): Promise<void> {
|
|
const src = await driver.getSource();
|
|
const hit = /sign in with this email|this email address|security level|Manage Accounts page|link another login/i.test(src);
|
|
if (hit) {
|
|
await tapText(driver, 'Cancel');
|
|
await sleep(800);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 忘记密码:登录表单 → "Forgot Password" → 输入邮箱 → Get Verification Code → 收码(单框)→ 设新密码(2框)→ Confirm。
|
|
* Forgot Password 页无 3 个协议勾选(与注册不同)。用注册返回的 { email, token } 收重置码。
|
|
* @returns 新密码
|
|
*/
|
|
export async function forgotPassword(
|
|
driver: DeviceDriver,
|
|
params: { email: string; token: string; newPassword?: string }
|
|
): Promise<string> {
|
|
const { getVerificationCode } = await import('./account-code.helper');
|
|
const newPassword = params.newPassword || 'Reset1234a';
|
|
|
|
await goToLoginForm(driver);
|
|
// 点 Forgot Password 展开重置区,等 "Enter email address" 出现(链式登出后有 Loading,需重试)
|
|
for (let i = 0; i < 4 && !(await sourceHas(driver, 'Enter email address')); i++) {
|
|
await tapText(driver, 'Forgot Password');
|
|
await waitForText(driver, 'Enter email address', 4000);
|
|
}
|
|
|
|
// 输入邮箱
|
|
if (driver.platform === 'ios') {
|
|
const emailEl = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null);
|
|
if (!emailEl) throw new Error('iOS Forgot Password 页未找到邮箱输入框(TextField)');
|
|
await driver.tapElement(emailEl).catch(() => {}); await sleep(300);
|
|
await driver.clearText(emailEl).catch(() => {});
|
|
await driver.typeText(emailEl, params.email); await sleep(400);
|
|
for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
|
|
} else {
|
|
const emailEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Enter email")');
|
|
if (!emailEd) throw new Error('Forgot Password 页未找到邮箱输入框');
|
|
await driver.clearText(emailEd).catch(() => {});
|
|
await driver.typeText(emailEd, params.email);
|
|
await sleep(400);
|
|
try { await driver.goBack(); } catch { /* 收键盘 */ }
|
|
}
|
|
|
|
// 发码(忘记密码无 3 协议;若意外有 Terms 弹框则 Agree)
|
|
await tapText(driver, 'Get Verification Code');
|
|
await sleep(2000);
|
|
await tapText(driver, 'Agree');
|
|
await sleep(2500);
|
|
|
|
// 取重置码 + 输入(单框)。发码与取码并发,验证码页("Verify It's You")可能稍后才渲染 → 等框出现再输。
|
|
const code = await getVerificationCode(params.token, { timeoutMs: 60000 });
|
|
console.log(`忘记密码 ${params.email} 重置码=${code}`);
|
|
let codeEd: string | null = null;
|
|
for (let i = 0; i < 10 && !codeEd; i++) {
|
|
codeEd = driver.platform === 'ios'
|
|
? await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null)
|
|
: await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")');
|
|
if (!codeEd) await sleep(1000);
|
|
}
|
|
if (!codeEd) throw new Error('重置码输入框未找到');
|
|
await driver.tapElement(codeEd).catch(() => {});
|
|
await driver.typeText(codeEd, code);
|
|
await sleep(2000);
|
|
if (driver.platform === 'ios') { for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
|
|
else { try { await driver.goBack(); } catch { /* 收键盘 */ } }
|
|
for (const b of ['Next', 'Confirm', 'Continue', '下一步']) {
|
|
if (await sourceHas(driver, b)) { await tapText(driver, b); await sleep(2000); break; }
|
|
}
|
|
|
|
// 设新密码(2 框)→ 保存
|
|
await sleep(1500);
|
|
if (driver.platform === 'ios') {
|
|
let sfs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => [] as string[]);
|
|
for (let i = 0; i < 6 && sfs.length < 2; i++) { await sleep(1000); sfs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => [] as string[]); }
|
|
if (sfs.length < 2) throw new Error(`iOS 新密码页 SecureTextField <2(实际 ${sfs.length})`);
|
|
await driver.tapElement(sfs[0]).catch(() => {}); await driver.typeText(sfs[0], newPassword); await sleep(300);
|
|
await driver.tapElement(sfs[1]).catch(() => {}); await driver.typeText(sfs[1], newPassword); await sleep(300);
|
|
for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
|
|
// iOS 保存按钮是 "OK",显式重试点
|
|
let ok = false;
|
|
for (let i = 0; i < 6 && !ok; i++) { if (await tapText(driver, 'OK')) ok = true; else await sleep(1000); }
|
|
if (!ok) console.log('WARN: iOS 新密码页未点到 OK');
|
|
} else {
|
|
const pwds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
|
|
if (pwds.length < 2) throw new Error(`新密码页输入框 <2(实际 ${pwds.length})`);
|
|
await driver.typeText(pwds[0], newPassword);
|
|
await sleep(300);
|
|
await driver.typeText(pwds[1], newPassword);
|
|
await sleep(300);
|
|
try { await driver.goBack(); } catch { /* 收键盘 */ }
|
|
for (const b of ['Confirm', 'Done', 'OK', 'Submit', '确定', '完成']) {
|
|
if (await tapText(driver, b)) break;
|
|
}
|
|
}
|
|
await sleep(6000);
|
|
await driver.dismissPopupIfPresent();
|
|
console.log(`忘记密码完成,新密码: ${newPassword}`);
|
|
return newPassword;
|
|
}
|
|
|
|
/**
|
|
* 注销(删除)当前登录账号。Android 实测流程(2026-06):
|
|
* Profile → Manage Accounts → "Delete Account"(进 Account Deletion Information 页)
|
|
* → 底部 destroyBto 按钮 → 确认框 "OK" → "Verify It's You" 码页(单框)输入码
|
|
* → "Canceling your account..."(注销已提交,带 Withdraw 撤销入口)。
|
|
* 注销码发到账号邮箱,需传该邮箱的 mail.tm token(getMailboxToken 取)。
|
|
* @returns 是否到达 "Canceling your account" 成功页
|
|
*/
|
|
export async function deleteAccount(driver: DeviceDriver, params: { token: string }): Promise<boolean> {
|
|
const { getVerificationCode } = await import('./account-code.helper');
|
|
|
|
await goToProfile(driver);
|
|
await tapText(driver, 'Manage Accounts');
|
|
await waitForText(driver, 'Delete Account', 8000);
|
|
await tapText(driver, 'Delete Account'); // 菜单项 → Account Deletion Information 页
|
|
await waitForText(driver, 'Account Deletion Information', 8000);
|
|
|
|
// 进入销毁:Android 底部 destroyBto + 确认框 OK;iOS 实测流程不同 → "Next" → 确认框 "Confirm"(非 destroyBto/OK)
|
|
if (driver.platform === 'ios') {
|
|
await tapText(driver, 'Next'); await sleep(2000); // Account Deletion Information 页推进
|
|
await tapText(driver, 'Confirm'); await sleep(2500); // 确认框 Cancel|Confirm → Confirm
|
|
} else {
|
|
if (!(await tapId(driver, 'destroyBto'))) throw new Error('未找到 destroyBto 销毁按钮');
|
|
await sleep(2000);
|
|
await tapText(driver, 'OK'); // 确认框 "Delete Account | Cancel | OK" → OK
|
|
}
|
|
|
|
// 等 "Verify It's You" 码页渲染(确认后稍后才出)→ 取码输入
|
|
let codeEd: string | null = null;
|
|
for (let i = 0; i < 12 && !codeEd; i++) {
|
|
codeEd = driver.platform === 'ios'
|
|
? await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null)
|
|
: await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")');
|
|
if (!codeEd) await sleep(1000);
|
|
}
|
|
if (!codeEd) throw new Error('注销验证码输入框未找到');
|
|
const code = await getVerificationCode(params.token, { timeoutMs: 60000 });
|
|
console.log(`注销验证码=${code}`);
|
|
await driver.tapElement(codeEd).catch(() => {});
|
|
await driver.typeText(codeEd, code);
|
|
await sleep(1500);
|
|
const afterType = await driver.getSource();
|
|
console.log(`[DEL诊断] 输码后 含Canceling=${afterType.includes('Canceling')} 含Account Security=${afterType.includes('Account Security')} 按钮=${JSON.stringify([...new Set(Array.from(afterType.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t && t.length < 20))].slice(0, 12))}`);
|
|
// 若已自动提交到成功页,别再 goBack(会把成功页退回);否则收键盘
|
|
if (!afterType.includes('Canceling your account')) {
|
|
if (driver.platform === 'ios') { for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
|
|
else { try { await driver.goBack(); } catch { /* 收键盘 */ } }
|
|
const afterBack = await driver.getSource();
|
|
console.log(`[DEL诊断] goBack后 含Canceling=${afterBack.includes('Canceling')} 含Account Security=${afterBack.includes('Account Security')}`);
|
|
// 单框输满通常自动提交;若有确认按钮也点
|
|
for (const b of ['Confirm', 'OK', 'Next', '确定', 'Delete', 'Delete Account']) {
|
|
if (await sourceHas(driver, `text="${b}"`)) { await tapText(driver, b); break; }
|
|
}
|
|
}
|
|
|
|
const ok = await waitForText(driver, 'Canceling your account', 15000);
|
|
console.log(ok ? '注销已提交(Canceling your account)' : '未到注销成功页');
|
|
return ok;
|
|
}
|
|
|
|
/**
|
|
* 第三方(Google)登录。Android 实测流程(2026-06):
|
|
* 登录表单 → 点 "Sign in by a third-party account" 区的 lastThirdLogin 图标(上次用的=Google)
|
|
* → 拉起系统 Google 账号浮层(com.google.android.gms,需设备已登 Google 账号)
|
|
* → 点 continue_button("继续以…的身份登录")→ ~10s 回 App,登录成功(MainActivity)。
|
|
* 注:lastThirdLogin 是"上次使用的第三方",本机为 Google;首次/无历史时入口图标可能不同。
|
|
* 需设备预先登录了一个 Google 账号(账号浮层会直接给 one-tap 继续)。
|
|
* @returns 是否登录成功
|
|
*/
|
|
export async function signInWithGoogle(driver: DeviceDriver): Promise<boolean> {
|
|
await goToLoginForm(driver); // 到登录表单(登出态)
|
|
|
|
// 点第三方登录入口(Google = lastThirdLogin)
|
|
let g = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/lastThirdLogin');
|
|
for (let i = 0; i < 5 && !g; i++) { await sleep(800); g = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/lastThirdLogin'); }
|
|
if (!g) { console.log('FAIL: 未找到第三方登录入口 lastThirdLogin'); return false; }
|
|
await driver.tapElement(g);
|
|
await sleep(4000);
|
|
|
|
// 三星新流程:点 Google 后弹「选择账号 以继续使用SwitchBot」账号选择器 → 选第一个账号(任意)。
|
|
// (旧机型为 one-tap continue_button 浮层;Google 记住授权时也可能直接登录。三者都兼容。)
|
|
for (let i = 0; i < 8; i++) {
|
|
const src = await driver.getSource();
|
|
if (src.includes('Add a Device') || src.includes('content-desc="Home"') || src.includes('Manage Homes')) {
|
|
console.log(`Google 登录:已直接登录成功(无需选账号, +${i}s)`);
|
|
return true;
|
|
}
|
|
if (/选择账号|Choose an account|Choose account/i.test(src)) {
|
|
// 账号行:点第一个含 "@" 的邮箱文本即选中该账号
|
|
const acct = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("@")');
|
|
if (acct) { await driver.tapElement(acct); console.log('已选择 Google 账号(任意)'); await sleep(5000); break; }
|
|
}
|
|
if (await driver.findElementRaw('id', 'com.google.android.gms:id/continue_button')) break; // 旧 one-tap 流程
|
|
await sleep(1000);
|
|
}
|
|
|
|
// 选账号后:可能弹 continue_button 确认,或直接 OAuth 回调登录回 App。
|
|
for (let i = 0; i < 10; i++) {
|
|
const src = await driver.getSource();
|
|
if (src.includes('Add a Device') || src.includes('content-desc="Home"') || src.includes('Manage Homes')) {
|
|
console.log(`Google 登录:已回到 App (+${i}s)`);
|
|
break;
|
|
}
|
|
const cont = await driver.findElementRaw('id', 'com.google.android.gms:id/continue_button');
|
|
if (cont) { await driver.tapElement(cont); console.log('点 Google continue_button'); await sleep(7000); break; }
|
|
await sleep(1000);
|
|
}
|
|
|
|
await sleep(2000);
|
|
const ok = await isLoggedIn(driver);
|
|
console.log(`Google 第三方登录: ${ok}`);
|
|
return ok;
|
|
}
|