Merge remote-tracking branch 'origin/dev'
This commit is contained in:
+2
-1
@@ -8,4 +8,5 @@
|
||||
/data/account-run-history.json
|
||||
.npm-test.log
|
||||
.omx/
|
||||
/node_modules
|
||||
/node_modules
|
||||
/.runtime
|
||||
|
||||
+654
-50
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,26 @@
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function isPhoneNumberSupplyExhaustedFailure(errorLike) {
|
||||
const message = String(
|
||||
typeof errorLike === 'string'
|
||||
? errorLike
|
||||
: (errorLike?.message || errorLike || '')
|
||||
).trim();
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const hasGlobalNoSupplySignal = /Step\s*9:\s*all\s+provider\s+candidates\s+failed\s+to\s+acquire\s+number|(?:HeroSMS|5sim|NexSMS)\s+no\s+numbers\s+available\s+across|no\s+numbers\s+within\s+maxPrice|no\s+free\s+phones|numbers?\s+not\s+found/i.test(message);
|
||||
if (!hasGlobalNoSupplySignal) {
|
||||
return false;
|
||||
}
|
||||
const hasRecoverableStep9RotationSignal = /phone\s+verification\s+did\s+not\s+succeed\s+after\s+\d+\s+number\s+replacements|sms_timeout_after_|route_405_retry_loop|resend_throttled|activation_not_found|order\s+not\s+found/i.test(message);
|
||||
if (hasRecoverableStep9RotationSignal) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldKeepCustomMailProviderPoolEmail(state = {}) {
|
||||
return String(state?.mailProvider || '').trim().toLowerCase() === 'custom'
|
||||
&& Array.isArray(state?.customMailProviderPool)
|
||||
@@ -482,10 +502,15 @@
|
||||
const reason = getErrorMessage(err);
|
||||
roundSummary.failureReasons.push(reason);
|
||||
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
|
||||
const blockedByPhoneSupplyExhausted = isPhoneNumberSupplyExhaustedFailure(err);
|
||||
const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function'
|
||||
&& !keepSameEmailUntilAddPhone
|
||||
&& isSignupUserAlreadyExistsFailure(err);
|
||||
const canRetry = !blockedByAddPhone && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound;
|
||||
const canRetry = !blockedByAddPhone
|
||||
&& !blockedByPhoneSupplyExhausted
|
||||
&& !blockedBySignupUserAlreadyExists
|
||||
&& autoRunSkipFailures
|
||||
&& attemptRun < maxAttemptsForRound;
|
||||
|
||||
await setState({
|
||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||
@@ -561,6 +586,41 @@
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockedByPhoneSupplyExhausted) {
|
||||
roundSummary.status = 'failed';
|
||||
roundSummary.finalFailureReason = reason;
|
||||
await setState({
|
||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||
});
|
||||
await appendRoundRecordIfNeeded('failed', reason);
|
||||
cancelPendingCommands('当前轮因接码号池不可用已终止。');
|
||||
await broadcastStopToContentScripts();
|
||||
if (!autoRunSkipFailures) {
|
||||
await addLog(
|
||||
`第 ${targetRun}/${totalRuns} 轮触发接码号池不可用,自动重试未开启,当前自动运行将停止。`,
|
||||
'warn'
|
||||
);
|
||||
stoppedEarly = true;
|
||||
await broadcastAutoRunStatus('stopped', {
|
||||
currentRun: targetRun,
|
||||
totalRuns,
|
||||
attemptRun,
|
||||
sessionId: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
await addLog(`第 ${targetRun}/${totalRuns} 轮接码号池暂不可用,本轮将直接失败并跳过剩余重试。`, 'warn');
|
||||
await addLog(
|
||||
targetRun < totalRuns
|
||||
? `第 ${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,自动流程将继续下一轮。`
|
||||
: `第 ${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,已无后续轮次,本次自动运行结束。`,
|
||||
'warn'
|
||||
);
|
||||
forceFreshTabsNextRun = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (canRetry) {
|
||||
const retryIndex = attemptRun;
|
||||
if (isRestartCurrentAttemptError(err)) {
|
||||
|
||||
@@ -284,9 +284,16 @@
|
||||
}
|
||||
if (generator === 'icloud') {
|
||||
const stateFetchMode = String(mergedState.icloudFetchMode || '').trim().toLowerCase();
|
||||
return fetchIcloudHideMyEmail({
|
||||
const icloudOptions = {
|
||||
generateNew: Boolean(options.generateNew) || stateFetchMode === 'always_new',
|
||||
});
|
||||
};
|
||||
if (mergedState.icloudHostPreference !== undefined) {
|
||||
icloudOptions.hostPreference = mergedState.icloudHostPreference;
|
||||
}
|
||||
if (mergedState.preferredIcloudHost !== undefined) {
|
||||
icloudOptions.preferredHost = mergedState.preferredIcloudHost;
|
||||
}
|
||||
return fetchIcloudHideMyEmail(icloudOptions);
|
||||
}
|
||||
if (generator === 'cloudflare') {
|
||||
return fetchCloudflareEmail(mergedState, options);
|
||||
|
||||
+42
-15
@@ -3,6 +3,7 @@ let ipProxyAuthListenerInstalled = false;
|
||||
let ipProxyErrorListenerInstalled = false;
|
||||
let currentIpProxyAuthEntry = null;
|
||||
let ipProxyExitDetectionToken = 0;
|
||||
let ipProxyProbeInFlightPromise = null;
|
||||
let lastAppliedIpProxyEntrySignature = '';
|
||||
let ipProxyAuthHostVariantToggle = false;
|
||||
const ipProxyHostResolveCache = new Map();
|
||||
@@ -573,6 +574,17 @@ function normalizeIpProxyServiceProfile(rawValue = {}) {
|
||||
const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue))
|
||||
? rawValue
|
||||
: {};
|
||||
const normalizeAutoSyncInterval = (value = '', fallback = 15) => {
|
||||
const rawValue = String(value ?? '').trim();
|
||||
if (!rawValue) {
|
||||
return Math.max(1, Math.min(1440, Number(fallback) || 15));
|
||||
}
|
||||
const numeric = Number.parseInt(rawValue, 10);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return Math.max(1, Math.min(1440, Number(fallback) || 15));
|
||||
}
|
||||
return Math.max(1, Math.min(1440, numeric));
|
||||
};
|
||||
return {
|
||||
mode: normalizeIpProxyMode(raw.mode),
|
||||
apiUrl: String(raw.apiUrl || '').trim(),
|
||||
@@ -580,6 +592,8 @@ function normalizeIpProxyServiceProfile(rawValue = {}) {
|
||||
accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''),
|
||||
accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''),
|
||||
poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20),
|
||||
autoSyncEnabled: Boolean(raw.autoSyncEnabled),
|
||||
autoSyncIntervalMinutes: normalizeAutoSyncInterval(raw.autoSyncIntervalMinutes, 15),
|
||||
host: String(raw.host || '').trim(),
|
||||
port: String(normalizeIpProxyPort(raw.port || '') || ''),
|
||||
protocol: normalizeIpProxyProtocol(raw.protocol),
|
||||
@@ -597,6 +611,8 @@ function buildIpProxyServiceProfileFromState(state = {}) {
|
||||
accountSessionPrefix: state?.ipProxyAccountSessionPrefix,
|
||||
accountLifeMinutes: state?.ipProxyAccountLifeMinutes,
|
||||
poolTargetCount: state?.ipProxyPoolTargetCount,
|
||||
autoSyncEnabled: state?.ipProxyAutoSyncEnabled,
|
||||
autoSyncIntervalMinutes: state?.ipProxyAutoSyncIntervalMinutes,
|
||||
host: state?.ipProxyHost,
|
||||
port: state?.ipProxyPort,
|
||||
protocol: state?.ipProxyProtocol,
|
||||
@@ -1595,20 +1611,14 @@ function applyExitRegionExpectation(status = {}, expectedRegion = '') {
|
||||
const authHint = authSummary ? ` 诊断:probe:${authSummary}` : '';
|
||||
const mismatchMessage = `代理出口地区与预期不一致:期望 ${expected},实际 ${detected || 'unknown'}${sourceHint}。${usernameHint}${missingAuthChallengeHint}${authHint}`.trim();
|
||||
if (normalizedProvider === '711proxy') {
|
||||
if (missingAuthChallenge) {
|
||||
return {
|
||||
...status,
|
||||
applied: false,
|
||||
reason: 'connectivity_failed',
|
||||
error: mismatchMessage,
|
||||
warning: '',
|
||||
};
|
||||
}
|
||||
const warningPrefix = missingAuthChallenge
|
||||
? '地区校验未通过且未触发代理鉴权挑战,疑似匿名链路;先保留代理接管并给出强告警:'
|
||||
: '地区校验未通过,但已保留代理接管:';
|
||||
return {
|
||||
...status,
|
||||
applied: true,
|
||||
reason: 'applied_with_warning',
|
||||
warning: `地区校验未通过,但已保留代理接管:${mismatchMessage}`,
|
||||
warning: `${warningPrefix}${mismatchMessage}`,
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
@@ -3172,9 +3182,11 @@ async function refreshIpProxyPool(options = {}) {
|
||||
forceAuthRebind: true,
|
||||
suppressAuthRebind: false,
|
||||
resetNetworkState: true,
|
||||
skipExitProbe: false,
|
||||
skipExitProbe: Boolean(options?.skipExitProbe),
|
||||
}
|
||||
: {
|
||||
skipExitProbe: Boolean(options?.skipExitProbe),
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3249,9 +3261,11 @@ async function switchIpProxy(direction = 'next', options = {}) {
|
||||
forceAuthRebind: true,
|
||||
suppressAuthRebind: false,
|
||||
resetNetworkState: true,
|
||||
skipExitProbe: false,
|
||||
skipExitProbe: Boolean(options?.skipExitProbe),
|
||||
}
|
||||
: {
|
||||
skipExitProbe: Boolean(options?.skipExitProbe),
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
const summary = buildProxyPoolSummary(pool, nextIndex);
|
||||
@@ -3299,7 +3313,7 @@ async function changeIpProxyExit(options = {}) {
|
||||
forceAuthRebind: true,
|
||||
suppressAuthRebind: false,
|
||||
resetNetworkState: true,
|
||||
skipExitProbe: false,
|
||||
skipExitProbe: Boolean(options?.skipExitProbe),
|
||||
});
|
||||
|
||||
const runtime = getIpProxyRuntimeSnapshot(state, mode, provider);
|
||||
@@ -3405,6 +3419,10 @@ async function tryRecoverApiProxyByRotation(options = {}) {
|
||||
}
|
||||
|
||||
async function probeIpProxyExit(options = {}) {
|
||||
if (ipProxyProbeInFlightPromise) {
|
||||
return ipProxyProbeInFlightPromise;
|
||||
}
|
||||
const probePromise = (async () => {
|
||||
const state = options.state || await getState();
|
||||
if (!state?.ipProxyEnabled) {
|
||||
const status = {
|
||||
@@ -3599,6 +3617,15 @@ async function probeIpProxyExit(options = {}) {
|
||||
}
|
||||
await updateIpProxyRuntimeStatus(normalizedFinalStatus);
|
||||
return { proxyRouting: normalizedFinalStatus };
|
||||
})();
|
||||
ipProxyProbeInFlightPromise = probePromise;
|
||||
try {
|
||||
return await probePromise;
|
||||
} finally {
|
||||
if (ipProxyProbeInFlightPromise === probePromise) {
|
||||
ipProxyProbeInFlightPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureIpProxySettingsAppliedFromCurrentState(options = {}) {
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
function isVerificationMailPollingError(error) {
|
||||
const message = getErrorMessage(error);
|
||||
return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
|
||||
return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s|405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/(?:email|phone)-verification/i.test(message);
|
||||
}
|
||||
|
||||
function isAddPhoneAuthFailure(error) {
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
isStopError,
|
||||
isTabAlive,
|
||||
launchAutoRunTimerPlan,
|
||||
ensureIpProxyAutoSyncAlarm,
|
||||
clearIpProxyAutoSyncAlarm,
|
||||
runIpProxyAutoSync,
|
||||
listIcloudAliases,
|
||||
listLuckmailPurchasesForManagement,
|
||||
refreshIpProxyPool,
|
||||
@@ -328,7 +331,11 @@
|
||||
const step5Status = latestState.stepStatuses?.[5];
|
||||
if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') {
|
||||
await setStepStatus(5, 'skipped');
|
||||
await addLog('步骤 4:检测到账号已直接进入已登录态,已自动跳过步骤 5。', 'warn');
|
||||
if (payload.skipProfileStepReason === 'combined_verification_profile') {
|
||||
await addLog('步骤 4:当前验证码页已内嵌完成注册资料提交,已自动跳过步骤 5。', 'warn');
|
||||
} else {
|
||||
await addLog('步骤 4:检测到账号已直接进入已登录态,已自动跳过步骤 5。', 'warn');
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -716,6 +723,12 @@
|
||||
oauthFlowDeadlineSourceUrl: null,
|
||||
} : {}),
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'icloudHostPreference')) {
|
||||
const nextHostPreference = String(updates.icloudHostPreference || '').trim().toLowerCase();
|
||||
stateUpdates.preferredIcloudHost = nextHostPreference === 'icloud.com' || nextHostPreference === 'icloud.com.cn'
|
||||
? nextHostPreference
|
||||
: '';
|
||||
}
|
||||
if (stepModeChanged && typeof getStepIdsForState === 'function') {
|
||||
const nextStateForSteps = { ...currentState, ...stateUpdates };
|
||||
stateUpdates.stepStatuses = Object.fromEntries(
|
||||
@@ -725,6 +738,19 @@
|
||||
}
|
||||
await setState(stateUpdates);
|
||||
const mergedState = await getState();
|
||||
const hasIpProxyAutoSyncSettingChanged = (
|
||||
Object.prototype.hasOwnProperty.call(updates, 'ipProxyAutoSyncEnabled')
|
||||
|| Object.prototype.hasOwnProperty.call(updates, 'ipProxyAutoSyncIntervalMinutes')
|
||||
);
|
||||
if (hasIpProxyAutoSyncSettingChanged) {
|
||||
if (Boolean(mergedState?.ipProxyAutoSyncEnabled)) {
|
||||
if (typeof ensureIpProxyAutoSyncAlarm === 'function') {
|
||||
await ensureIpProxyAutoSyncAlarm(mergedState);
|
||||
}
|
||||
} else if (typeof clearIpProxyAutoSyncAlarm === 'function') {
|
||||
await clearIpProxyAutoSyncAlarm();
|
||||
}
|
||||
}
|
||||
const hasIpProxyUpdates = Object.keys(updates).some((key) => key.startsWith('ipProxy'));
|
||||
const hasIpProxyEnabledUpdate = Object.prototype.hasOwnProperty.call(updates, 'ipProxyEnabled');
|
||||
const previousIpProxyEnabled = Boolean(currentState?.ipProxyEnabled);
|
||||
@@ -773,11 +799,19 @@
|
||||
).trim().toLowerCase() === 'gopay'
|
||||
? 'GoPay'
|
||||
: 'PayPal';
|
||||
await addLog(`Plus 鏀粯鏂瑰紡宸插垏鎹负 ${selectedPlusPaymentMethod}锛屽凡鏇存柊瀵瑰簲鐨?Plus 姝ラ銆?`, 'info');
|
||||
await addLog(`Plus 支付方式已切换为 ${selectedPlusPaymentMethod},已更新对应的 Plus 步骤。`, 'info');
|
||||
}
|
||||
return { ok: true, state: await getState(), proxyRouting };
|
||||
}
|
||||
|
||||
case 'RUN_IP_PROXY_AUTO_SYNC_NOW': {
|
||||
if (typeof runIpProxyAutoSync !== 'function') {
|
||||
throw new Error('IP 代理自动同步能力尚未接入。');
|
||||
}
|
||||
const result = await runIpProxyAutoSync('manual');
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
case 'REFRESH_IP_PROXY_POOL': {
|
||||
if (typeof refreshIpProxyPool !== 'function') {
|
||||
throw new Error('IP 代理池能力尚未接入。');
|
||||
@@ -785,6 +819,7 @@
|
||||
const result = await refreshIpProxyPool({
|
||||
maxItems: message.payload?.maxItems,
|
||||
mode: message.payload?.mode,
|
||||
skipExitProbe: message.payload?.skipExitProbe,
|
||||
});
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
@@ -797,6 +832,7 @@
|
||||
maxItems: message.payload?.maxItems,
|
||||
mode: message.payload?.mode,
|
||||
forceRefresh: message.payload?.forceRefresh,
|
||||
skipExitProbe: message.payload?.skipExitProbe,
|
||||
});
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
@@ -807,6 +843,7 @@
|
||||
}
|
||||
const result = await changeIpProxyExit({
|
||||
mode: message.payload?.mode,
|
||||
skipExitProbe: message.payload?.skipExitProbe,
|
||||
});
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
@@ -833,7 +870,7 @@
|
||||
);
|
||||
const timeoutMs = Number(message.payload?.timeoutMs) > 0
|
||||
? Number(message.payload.timeoutMs)
|
||||
: (is711AccountMode ? (shouldPreRebindBeforeProbe ? 8000 : 6000) : undefined);
|
||||
: (is711AccountMode ? (shouldPreRebindBeforeProbe ? 15000 : 12000) : undefined);
|
||||
|
||||
// 手动“检测出口”前先轻量应用当前配置,避免读取到旧代理链路状态。
|
||||
if (probeState?.ipProxyEnabled && typeof applyIpProxySettingsFromState === 'function') {
|
||||
|
||||
+3022
-200
File diff suppressed because it is too large
Load Diff
@@ -316,6 +316,8 @@
|
||||
let mailPollingAttempt = 1;
|
||||
let lastMailPollingError = null;
|
||||
let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
|
||||
let retryWithoutStep7Streak = 0;
|
||||
const maxRetryWithoutStep7Streak = 3;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
@@ -331,6 +333,7 @@
|
||||
if (Number(result?.lastResendAt) > 0) {
|
||||
stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0);
|
||||
}
|
||||
retryWithoutStep7Streak = 0;
|
||||
return;
|
||||
} catch (err) {
|
||||
const visibleStep = getVisibleStep(currentState, 8);
|
||||
@@ -361,8 +364,21 @@
|
||||
|
||||
mailPollingAttempt += 1;
|
||||
if (retryWithoutStep7) {
|
||||
retryWithoutStep7Streak += 1;
|
||||
if (retryWithoutStep7Streak > maxRetryWithoutStep7Streak) {
|
||||
await addLog(
|
||||
`步骤 ${visibleStep}:邮箱通信异常在当前链路已连续重试 ${retryWithoutStep7Streak} 次,改为回到步骤 ${authLoginStep} 重新发起授权链路,避免空轮询循环。`,
|
||||
'warn'
|
||||
);
|
||||
await rerunStep7ForStep8Recovery({
|
||||
logMessage: `步骤 ${visibleStep}:邮箱通信异常持续未恢复,正在回到步骤 ${authLoginStep} 重新发起登录流程...`,
|
||||
});
|
||||
currentState = await getState();
|
||||
retryWithoutStep7Streak = 0;
|
||||
continue;
|
||||
}
|
||||
await addLog(
|
||||
`步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}。`,
|
||||
`步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}(连续同链路重试 ${retryWithoutStep7Streak}/${maxRetryWithoutStep7Streak})。`,
|
||||
'warn'
|
||||
);
|
||||
const latestState = await getState();
|
||||
@@ -390,6 +406,7 @@
|
||||
}
|
||||
continue;
|
||||
}
|
||||
retryWithoutStep7Streak = 0;
|
||||
await addLog(
|
||||
isStep8RestartStep7Error(currentError)
|
||||
? `步骤 ${visibleStep}:检测到认证页进入重试/超时报错状态,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...`
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
chrome,
|
||||
completeStepFromBackground,
|
||||
confirmCustomVerificationStepBypass,
|
||||
generateRandomBirthday,
|
||||
generateRandomName,
|
||||
ensureMail2925MailboxSession,
|
||||
ensureIcloudMailSession,
|
||||
getMailConfig,
|
||||
@@ -19,12 +21,29 @@
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
|
||||
resolveVerificationStep,
|
||||
reuseOrCreateTab,
|
||||
sendToContentScript,
|
||||
sendToContentScriptResilient,
|
||||
isRetryableContentScriptTransportError = () => false,
|
||||
shouldUseCustomRegistrationEmail,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
|
||||
throwIfStopped,
|
||||
} = deps;
|
||||
|
||||
function buildSignupProfileForVerificationStep() {
|
||||
const name = typeof generateRandomName === 'function' ? generateRandomName() : null;
|
||||
const birthday = typeof generateRandomBirthday === 'function' ? generateRandomBirthday() : null;
|
||||
if (!name?.firstName || !name?.lastName || !birthday) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
firstName: name.firstName,
|
||||
lastName: name.lastName,
|
||||
year: birthday.year,
|
||||
month: birthday.month,
|
||||
day: birthday.day,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedMail2925MailboxEmail(state = {}) {
|
||||
if (Boolean(state?.mail2925UseAccountPool)) {
|
||||
const currentAccountId = String(state?.currentMail2925AccountId || '').trim();
|
||||
@@ -80,24 +99,73 @@
|
||||
throwIfStopped();
|
||||
await addLog('步骤 4:正在确认注册验证码页面是否就绪,必要时自动恢复密码页超时报错...');
|
||||
|
||||
const prepareResult = await sendToContentScriptResilient(
|
||||
'signup-page',
|
||||
{
|
||||
type: 'PREPARE_SIGNUP_VERIFICATION',
|
||||
step: 4,
|
||||
source: 'background',
|
||||
payload: {
|
||||
password: state.password || state.customPassword || '',
|
||||
prepareSource: 'step4_execute',
|
||||
prepareLogLabel: '步骤 4 执行',
|
||||
},
|
||||
const prepareRequest = {
|
||||
type: 'PREPARE_SIGNUP_VERIFICATION',
|
||||
step: 4,
|
||||
source: 'background',
|
||||
payload: {
|
||||
password: state.password || state.customPassword || '',
|
||||
prepareSource: 'step4_execute',
|
||||
prepareLogLabel: '步骤 4 执行',
|
||||
},
|
||||
{
|
||||
timeoutMs: 30000,
|
||||
retryDelayMs: 700,
|
||||
logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...',
|
||||
};
|
||||
const prepareTimeoutMs = 30000;
|
||||
const prepareResponseTimeoutMs = 30000;
|
||||
const prepareStartAt = Date.now();
|
||||
let prepareResult = null;
|
||||
|
||||
while (Date.now() - prepareStartAt < prepareTimeoutMs) {
|
||||
throwIfStopped();
|
||||
|
||||
try {
|
||||
prepareResult = typeof sendToContentScript === 'function'
|
||||
? await sendToContentScript('signup-page', prepareRequest, {
|
||||
responseTimeoutMs: prepareResponseTimeoutMs,
|
||||
})
|
||||
: await sendToContentScriptResilient('signup-page', prepareRequest, {
|
||||
timeoutMs: Math.max(1000, prepareTimeoutMs - (Date.now() - prepareStartAt)),
|
||||
responseTimeoutMs: prepareResponseTimeoutMs,
|
||||
retryDelayMs: 700,
|
||||
logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...',
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!isRetryableContentScriptTransportError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const remainingMs = Math.max(0, prepareTimeoutMs - (Date.now() - prepareStartAt));
|
||||
if (remainingMs <= 0) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recoverResult = await sendToContentScriptResilient('signup-page', {
|
||||
type: 'RECOVER_AUTH_RETRY_PAGE',
|
||||
step: 4,
|
||||
source: 'background',
|
||||
payload: {
|
||||
flow: 'signup',
|
||||
step: 4,
|
||||
timeoutMs: Math.min(12000, remainingMs),
|
||||
maxClickAttempts: 2,
|
||||
logLabel: '步骤 4:检测到注册认证重试页,正在点击“重试”恢复',
|
||||
},
|
||||
}, {
|
||||
timeoutMs: Math.min(12000, remainingMs),
|
||||
responseTimeoutMs: Math.min(12000, remainingMs),
|
||||
retryDelayMs: 700,
|
||||
logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...',
|
||||
});
|
||||
|
||||
if (recoverResult?.error) {
|
||||
throw new Error(recoverResult.error);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!prepareResult) {
|
||||
throw new Error('步骤 4:等待注册验证码页面就绪超时,请刷新认证页后重试。');
|
||||
}
|
||||
|
||||
if (prepareResult && prepareResult.error) {
|
||||
throw new Error(prepareResult.error);
|
||||
@@ -152,12 +220,14 @@
|
||||
LUCKMAIL_PROVIDER,
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
|
||||
].includes(mail.provider);
|
||||
const signupProfile = buildSignupProfileForVerificationStep();
|
||||
|
||||
await resolveVerificationStep(4, state, mail, {
|
||||
filterAfterTimestamp: verificationFilterAfterTimestamp,
|
||||
sessionKey: verificationSessionKey,
|
||||
disableTimeBudgetCap: mail.provider === '2925',
|
||||
requestFreshCodeFirst: shouldRequestFreshCodeFirst,
|
||||
signupProfile,
|
||||
resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER
|
||||
? 15000
|
||||
: ((mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
|
||||
|
||||
@@ -135,6 +135,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isSub2ApiTransientExchangeError(error) {
|
||||
const message = normalizeString(error?.message || error);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const tokenExchangeFailure = /auth\.openai\.com\/oauth\/token/i.test(message);
|
||||
const transientNetworkSignal = /unexpected\s+eof|eof|connection\s+refused|i\/o\s+timeout|context\s+deadline\s+exceeded|connection\s+reset|broken\s+pipe|failed\s+to\s+fetch|temporarily\s+unavailable|timeout/i.test(message);
|
||||
const transientExchangeUserSignal = /token_exchange_user_error|invalid\s+request\.\s+please\s+try\s+again\s+later/i.test(message);
|
||||
if (transientExchangeUserSignal) {
|
||||
return true;
|
||||
}
|
||||
return tokenExchangeFailure && transientNetworkSignal;
|
||||
}
|
||||
|
||||
async function sleep(ms = 0) {
|
||||
const timeout = Math.max(0, Number(ms) || 0);
|
||||
if (!timeout) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, timeout));
|
||||
}
|
||||
|
||||
async function fetchCodex2ApiJson(origin, path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
|
||||
@@ -286,6 +306,7 @@
|
||||
}
|
||||
|
||||
async function executeSub2ApiStep10(state) {
|
||||
const visibleStep = resolvePlatformVerifyStep(state);
|
||||
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
|
||||
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
|
||||
}
|
||||
@@ -327,8 +348,8 @@
|
||||
injectSource: 'sub2api-panel',
|
||||
});
|
||||
|
||||
await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...');
|
||||
const result = await sendToContentScript('sub2api-panel', {
|
||||
await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`);
|
||||
const requestMessage = {
|
||||
type: 'EXECUTE_STEP',
|
||||
step: 10,
|
||||
source: 'background',
|
||||
@@ -345,12 +366,32 @@
|
||||
sub2apiGroupId: state.sub2apiGroupId,
|
||||
sub2apiDraftName: state.sub2apiDraftName,
|
||||
},
|
||||
}, {
|
||||
responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
};
|
||||
const maxExchangeAttempts = 3;
|
||||
let lastError = null;
|
||||
for (let attempt = 1; attempt <= maxExchangeAttempts; attempt += 1) {
|
||||
try {
|
||||
const result = await sendToContentScript('sub2api-panel', requestMessage, {
|
||||
responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
||||
});
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isSub2ApiTransientExchangeError(error) || attempt >= maxExchangeAttempts) {
|
||||
throw error;
|
||||
}
|
||||
await addLog(
|
||||
`步骤 ${visibleStep}:SUB2API 回调交换出现临时网络波动(${error.message}),正在重试 ${attempt + 1}/${maxExchangeAttempts}...`,
|
||||
'warn'
|
||||
);
|
||||
await sleep(1200 * attempt);
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
(function attachBackgroundVerificationFlow(root, factory) {
|
||||
root.MultiPageBackgroundVerificationFlow = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundVerificationFlowModule() {
|
||||
const ICLOUD_MAIL_POLL_RESPONSE_TIMEOUT_CAP_MS = 18000;
|
||||
const ICLOUD_MAIL_POLL_TOTAL_TIMEOUT_CAP_MS = 22000;
|
||||
|
||||
function createVerificationFlowHelpers(deps = {}) {
|
||||
const {
|
||||
addLog,
|
||||
@@ -46,6 +49,33 @@
|
||||
return step === 4 ? '注册' : '登录';
|
||||
}
|
||||
|
||||
function capIcloudMailPollingTimeouts(mail, timedPoll) {
|
||||
const defaultResponseTimeoutMs = Math.max(1000, Number(timedPoll?.responseTimeoutMs) || 30000);
|
||||
const defaultTimeoutMs = Math.max(defaultResponseTimeoutMs, Number(timedPoll?.timeoutMs) || defaultResponseTimeoutMs);
|
||||
if (mail?.source !== 'icloud-mail') {
|
||||
return {
|
||||
responseTimeoutMs: defaultResponseTimeoutMs,
|
||||
timeoutMs: defaultTimeoutMs,
|
||||
capped: false,
|
||||
};
|
||||
}
|
||||
|
||||
const cappedResponseTimeoutMs = Math.max(
|
||||
5000,
|
||||
Math.min(defaultResponseTimeoutMs, ICLOUD_MAIL_POLL_RESPONSE_TIMEOUT_CAP_MS)
|
||||
);
|
||||
const cappedTimeoutMs = Math.max(
|
||||
cappedResponseTimeoutMs,
|
||||
Math.min(defaultTimeoutMs, ICLOUD_MAIL_POLL_TOTAL_TIMEOUT_CAP_MS)
|
||||
);
|
||||
|
||||
return {
|
||||
responseTimeoutMs: cappedResponseTimeoutMs,
|
||||
timeoutMs: cappedTimeoutMs,
|
||||
capped: cappedResponseTimeoutMs < defaultResponseTimeoutMs || cappedTimeoutMs < defaultTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function isLikelyLoggedInChatgptHomeUrl(rawUrl) {
|
||||
const url = String(rawUrl || '').trim();
|
||||
if (!url) return false;
|
||||
@@ -375,6 +405,10 @@
|
||||
const baseMaxAttempts = Math.max(1, Number(nextPayload.maxAttempts) || 1);
|
||||
const disableTimeBudgetCap = Boolean(options.disableTimeBudgetCap);
|
||||
const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel);
|
||||
const minPollingResponseTimeoutMs = Math.max(
|
||||
3000,
|
||||
Number(options.minPollingResponseTimeoutMs) || 5000
|
||||
);
|
||||
|
||||
if (!disableTimeBudgetCap && remainingMs !== null) {
|
||||
nextPayload.maxAttempts = Math.max(
|
||||
@@ -386,7 +420,10 @@
|
||||
const defaultResponseTimeoutMs = Math.max(45000, nextPayload.maxAttempts * intervalMs + 25000);
|
||||
const responseTimeoutMs = disableTimeBudgetCap || remainingMs === null
|
||||
? defaultResponseTimeoutMs
|
||||
: Math.max(1000, Math.min(defaultResponseTimeoutMs, remainingMs));
|
||||
: Math.max(
|
||||
minPollingResponseTimeoutMs,
|
||||
Math.min(defaultResponseTimeoutMs, remainingMs)
|
||||
);
|
||||
|
||||
return {
|
||||
payload: nextPayload,
|
||||
@@ -583,9 +620,27 @@
|
||||
const resendIntervalMs = Math.max(0, Number(pollOverrides.resendIntervalMs) || 0);
|
||||
let lastResendAt = Number(pollOverrides.lastResendAt) || 0;
|
||||
let usedResendRequests = 0;
|
||||
let pollOnlyNoResendRounds = 0;
|
||||
let transportErrorStreak = 0;
|
||||
const maxTransportErrorStreak = mail?.source === 'icloud-mail' ? 6 : 4;
|
||||
const maxIcloudNoResendRounds = mail?.source === 'icloud-mail' ? 4 : 0;
|
||||
const hasExistingResendTimestamp = Number(lastResendAt) > 0;
|
||||
const initialRoundNoResendWindowMs = resendIntervalMs > 0
|
||||
? Math.max(10000, Math.min(45000, resendIntervalMs))
|
||||
: 0;
|
||||
const initialRoundNoResendUntil = hasExistingResendTimestamp
|
||||
? 0
|
||||
: (initialRoundNoResendWindowMs > 0 ? (Date.now() + initialRoundNoResendWindowMs) : 0);
|
||||
|
||||
for (let round = 1; round <= totalRounds; round++) {
|
||||
throwIfStopped();
|
||||
if (round === 1 && initialRoundNoResendUntil > 0) {
|
||||
const waitSeconds = Math.max(1, Math.ceil((initialRoundNoResendUntil - Date.now()) / 1000));
|
||||
await addLog(
|
||||
`步骤 ${step}:首次进入验证码轮询,先等待 ${waitSeconds} 秒观察新邮件,避免过早重复重发。`,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
if (round > 1) {
|
||||
lastResendAt = await requestVerificationCodeResend(step, pollOverrides);
|
||||
usedResendRequests += 1;
|
||||
@@ -619,6 +674,13 @@
|
||||
pollOverrides,
|
||||
`轮询${getVerificationCodeLabel(step)}验证码邮箱`
|
||||
);
|
||||
const timeoutWindow = capIcloudMailPollingTimeouts(mail, timedPoll);
|
||||
if (timeoutWindow.capped) {
|
||||
await addLog(
|
||||
`步骤 ${step}:iCloud 邮箱轮询已启用快速超时保护(${Math.ceil(timeoutWindow.timeoutMs / 1000)} 秒),避免页面无响应导致长时间卡住。`,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
const result = await sendToMailContentScriptResilient(
|
||||
mail,
|
||||
{
|
||||
@@ -628,9 +690,9 @@
|
||||
payload: timedPoll.payload,
|
||||
},
|
||||
{
|
||||
timeoutMs: timedPoll.timeoutMs,
|
||||
timeoutMs: timeoutWindow.timeoutMs,
|
||||
maxRecoveryAttempts: 2,
|
||||
responseTimeoutMs: timedPoll.responseTimeoutMs,
|
||||
responseTimeoutMs: timeoutWindow.responseTimeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -646,6 +708,8 @@
|
||||
throw new Error(`步骤 ${step}:再次收到了相同的${getVerificationCodeLabel(step)}验证码:${result.code}`);
|
||||
}
|
||||
|
||||
transportErrorStreak = 0;
|
||||
|
||||
return {
|
||||
...result,
|
||||
lastResendAt,
|
||||
@@ -661,16 +725,52 @@
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const isTransportError = isRetryableVerificationTransportError(err);
|
||||
if (isTransportError) {
|
||||
transportErrorStreak += 1;
|
||||
lastError = err;
|
||||
await addLog(`步骤 ${step}:${err.message}`, 'warn');
|
||||
if (transportErrorStreak >= maxTransportErrorStreak) {
|
||||
throw new Error(
|
||||
`步骤 ${step}:${mail?.label || '邮箱'}页面通信异常连续 ${transportErrorStreak} 次,已停止当前轮询以避免重复重发验证码。最后错误:${err.message}`
|
||||
);
|
||||
}
|
||||
const fallbackIntervalMs = Math.max(
|
||||
800,
|
||||
Math.min(
|
||||
3000,
|
||||
Number(payloadOverrides.intervalMs)
|
||||
|| Number(pollOverrides.intervalMs)
|
||||
|| 2000
|
||||
)
|
||||
);
|
||||
await sleepWithStop(fallbackIntervalMs);
|
||||
continue;
|
||||
}
|
||||
transportErrorStreak = 0;
|
||||
lastError = err;
|
||||
await addLog(`步骤 ${step}:${err.message}`, 'warn');
|
||||
}
|
||||
|
||||
if (mail?.source === 'icloud-mail' && maxIcloudNoResendRounds > 0) {
|
||||
pollOnlyNoResendRounds += 1;
|
||||
if (pollOnlyNoResendRounds >= maxIcloudNoResendRounds) {
|
||||
throw new Error(
|
||||
`步骤 ${step}:iCloud 邮箱连续 ${pollOnlyNoResendRounds} 轮轮询均未拿到验证码且未触发重发,已停止当前链路以避免空轮询循环,请刷新邮箱页后重试。`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const remainingBeforeResendMs = lastResendAt > 0
|
||||
? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt))
|
||||
: 0;
|
||||
if (remainingBeforeResendMs > 0) {
|
||||
const initialCooldownMs = (round === 1 && initialRoundNoResendUntil > 0)
|
||||
? Math.max(0, initialRoundNoResendUntil - Date.now())
|
||||
: 0;
|
||||
const effectiveCooldownMs = Math.max(remainingBeforeResendMs, initialCooldownMs);
|
||||
if (effectiveCooldownMs > 0) {
|
||||
await addLog(
|
||||
`步骤 ${step}:距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,继续刷新邮箱(第 ${round}/${maxRounds} 轮)...`,
|
||||
`步骤 ${step}:距离下次重新发送验证码还差 ${Math.ceil(effectiveCooldownMs / 1000)} 秒,继续刷新邮箱(第 ${round}/${maxRounds} 轮)...`,
|
||||
'info'
|
||||
);
|
||||
const configuredIntervalMs = Math.max(
|
||||
@@ -680,7 +780,7 @@
|
||||
|| 3000
|
||||
);
|
||||
const cooldownSleepMs = Math.min(
|
||||
remainingBeforeResendMs,
|
||||
effectiveCooldownMs,
|
||||
Math.max(1000, Math.min(configuredIntervalMs, 3000))
|
||||
);
|
||||
await sleepWithStop(cooldownSleepMs);
|
||||
@@ -840,6 +940,13 @@
|
||||
pollOverrides,
|
||||
`轮询${getVerificationCodeLabel(step)}验证码邮箱`
|
||||
);
|
||||
const timeoutWindow = capIcloudMailPollingTimeouts(mail, timedPoll);
|
||||
if (timeoutWindow.capped) {
|
||||
await addLog(
|
||||
`步骤 ${step}:iCloud 邮箱轮询已启用快速超时保护(${Math.ceil(timeoutWindow.timeoutMs / 1000)} 秒),避免页面无响应导致长时间卡住。`,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
const result = await sendToMailContentScriptResilient(
|
||||
mail,
|
||||
{
|
||||
@@ -849,9 +956,9 @@
|
||||
payload: timedPoll.payload,
|
||||
},
|
||||
{
|
||||
timeoutMs: timedPoll.timeoutMs,
|
||||
timeoutMs: timeoutWindow.timeoutMs,
|
||||
maxRecoveryAttempts: 2,
|
||||
responseTimeoutMs: timedPoll.responseTimeoutMs,
|
||||
responseTimeoutMs: timeoutWindow.responseTimeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -909,7 +1016,10 @@
|
||||
type: 'FILL_CODE',
|
||||
step,
|
||||
source: 'background',
|
||||
payload: { code },
|
||||
payload: {
|
||||
code,
|
||||
...(step === 4 && options.signupProfile ? { signupProfile: options.signupProfile } : {}),
|
||||
},
|
||||
};
|
||||
let result;
|
||||
if (typeof sendToContentScriptResilient === 'function') {
|
||||
@@ -1144,6 +1254,9 @@
|
||||
code: result.code,
|
||||
phoneVerificationRequired: Boolean(submitResult.addPhonePage),
|
||||
...(step === 4 && submitResult?.skipProfileStep ? { skipProfileStep: true } : {}),
|
||||
...(step === 4 && submitResult?.skipProfileStepReason
|
||||
? { skipProfileStepReason: submitResult.skipProfileStepReason }
|
||||
: {}),
|
||||
});
|
||||
triggerPostSuccessMailboxCleanup(step, mail);
|
||||
return {
|
||||
|
||||
@@ -71,8 +71,9 @@
|
||||
: false;
|
||||
const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text);
|
||||
const userAlreadyExistsBlocked = /user_already_exists/i.test(text);
|
||||
const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text);
|
||||
|
||||
if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
|
||||
if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -84,6 +85,7 @@
|
||||
titleMatched,
|
||||
detailMatched,
|
||||
routeErrorMatched,
|
||||
fetchFailedMatched,
|
||||
maxCheckAttemptsBlocked,
|
||||
userAlreadyExistsBlocked,
|
||||
};
|
||||
|
||||
+78
-5
@@ -1,5 +1,6 @@
|
||||
const ICLOUD_MAIL_PREFIX = '[MultiPage:icloud-mail]';
|
||||
const isTopFrame = window === window.top;
|
||||
const ICLOUD_POLL_SESSION_CACHE = new Map();
|
||||
|
||||
console.log(ICLOUD_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
@@ -12,7 +13,10 @@ function isMailApplicationFrame() {
|
||||
|
||||
if (isTopFrame) {
|
||||
console.log(ICLOUD_MAIL_PREFIX, 'Top frame detected; waiting for mail iframe.');
|
||||
} else {
|
||||
}
|
||||
|
||||
const shouldHandlePollEmailInCurrentFrame = !isTopFrame || isMailApplicationFrame();
|
||||
if (shouldHandlePollEmailInCurrentFrame) {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
if (!isMailApplicationFrame()) {
|
||||
@@ -221,19 +225,76 @@ if (isTopFrame) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePollSessionKey(payload = {}) {
|
||||
const raw = String(payload?.sessionKey || '').trim();
|
||||
if (raw) {
|
||||
return raw;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getOrCreatePollSessionBaseline(sessionKey, currentItems = []) {
|
||||
if (!sessionKey) {
|
||||
return {
|
||||
signatures: new Set(currentItems.map(buildItemSignature)),
|
||||
fallbackCarry: 0,
|
||||
fromCache: false,
|
||||
};
|
||||
}
|
||||
|
||||
const cached = ICLOUD_POLL_SESSION_CACHE.get(sessionKey);
|
||||
if (cached && cached.signatures instanceof Set) {
|
||||
return {
|
||||
signatures: new Set(cached.signatures),
|
||||
fallbackCarry: Math.max(0, Number(cached.fallbackCarry) || 0),
|
||||
fromCache: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
signatures: new Set(currentItems.map(buildItemSignature)),
|
||||
fallbackCarry: 0,
|
||||
fromCache: false,
|
||||
};
|
||||
}
|
||||
|
||||
function persistPollSessionBaseline(sessionKey, signatures, fallbackCarry = 0) {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
ICLOUD_POLL_SESSION_CACHE.set(sessionKey, {
|
||||
signatures: new Set(signatures || []),
|
||||
fallbackCarry: Math.max(0, Number(fallbackCarry) || 0),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (ICLOUD_POLL_SESSION_CACHE.size > 12) {
|
||||
const oldest = Array.from(ICLOUD_POLL_SESSION_CACHE.entries())
|
||||
.sort((left, right) => Number(left?.[1]?.updatedAt || 0) - Number(right?.[1]?.updatedAt || 0))
|
||||
.slice(0, ICLOUD_POLL_SESSION_CACHE.size - 12);
|
||||
oldest.forEach(([key]) => ICLOUD_POLL_SESSION_CACHE.delete(key));
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [] } = payload;
|
||||
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
|
||||
const FALLBACK_AFTER = 3;
|
||||
const pollSessionKey = normalizePollSessionKey(payload);
|
||||
const normalizedSenderFilters = senderFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean);
|
||||
const normalizedSubjectFilters = subjectFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean);
|
||||
|
||||
log(`步骤 ${step}:开始轮询 iCloud 邮箱(最多 ${maxAttempts} 次)`);
|
||||
await waitForElement('.content-container', 10000);
|
||||
await sleep(1500);
|
||||
|
||||
const existingSignatures = new Set(collectThreadItems().map(buildItemSignature));
|
||||
log(`步骤 ${step}:已记录当前 ${existingSignatures.size} 封旧邮件快照`);
|
||||
const currentItems = collectThreadItems();
|
||||
const sessionBaseline = getOrCreatePollSessionBaseline(pollSessionKey, currentItems);
|
||||
const existingSignatures = sessionBaseline.signatures;
|
||||
let fallbackCarry = sessionBaseline.fallbackCarry;
|
||||
if (sessionBaseline.fromCache) {
|
||||
log(`步骤 ${step}:已复用当前会话旧邮件快照(${existingSignatures.size} 封)。`);
|
||||
} else {
|
||||
log(`步骤 ${step}:已记录当前 ${existingSignatures.size} 封旧邮件快照`);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
log(`步骤 ${step}:正在轮询 iCloud 邮箱,第 ${attempt}/${maxAttempts} 次`);
|
||||
@@ -244,7 +305,7 @@ if (isTopFrame) {
|
||||
}
|
||||
|
||||
const items = collectThreadItems();
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
const useFallback = (fallbackCarry + attempt) > FALLBACK_AFTER;
|
||||
|
||||
for (const item of items) {
|
||||
const signature = buildItemSignature(item);
|
||||
@@ -287,6 +348,11 @@ if (isTopFrame) {
|
||||
|
||||
const source = useFallback && existingSignatures.has(signature) ? '回退匹配邮件' : '新邮件';
|
||||
log(`步骤 ${step}:已找到验证码:${code}(来源:${source})`, 'ok');
|
||||
persistPollSessionBaseline(
|
||||
pollSessionKey,
|
||||
new Set(collectThreadItems().map(buildItemSignature)),
|
||||
0
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
code,
|
||||
@@ -304,6 +370,13 @@ if (isTopFrame) {
|
||||
}
|
||||
}
|
||||
|
||||
fallbackCarry += maxAttempts;
|
||||
persistPollSessionBaseline(
|
||||
pollSessionKey,
|
||||
new Set(collectThreadItems().map(buildItemSignature)),
|
||||
fallbackCarry
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`${Math.round((maxAttempts * intervalMs) / 1000)} 秒后仍未在 iCloud 邮箱中找到新的匹配邮件。请手动检查收件箱。`
|
||||
);
|
||||
|
||||
+226
-48
@@ -19,7 +19,15 @@
|
||||
waitForElement,
|
||||
} = deps;
|
||||
const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::';
|
||||
const PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX = 'PHONE_ROUTE_405_RECOVERY_FAILED::';
|
||||
const PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS = 6000;
|
||||
const PHONE_RESEND_ROUTE_405_MAX_RECOVERIES = 2;
|
||||
const PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS = 12000;
|
||||
const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i;
|
||||
const PHONE_ROUTE_405_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/phone-verification/i;
|
||||
const PHONE_ROUTE_405_MAX_RECOVERY_CLICKS = 3;
|
||||
let lastPhoneRoute405RecoveryFailedAt = 0;
|
||||
let activePhoneResendPromise = null;
|
||||
|
||||
function dispatchInputEvents(element) {
|
||||
if (!element) return;
|
||||
@@ -35,6 +43,10 @@
|
||||
return digits;
|
||||
}
|
||||
|
||||
function isExplicitInternationalPhoneInput(value) {
|
||||
return /^\s*(?:\+|00)\s*\d/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function normalizeCountryLabel(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
@@ -151,23 +163,31 @@
|
||||
function toNationalPhoneNumber(value, dialCode) {
|
||||
const digits = normalizePhoneDigits(value);
|
||||
const normalizedDialCode = normalizePhoneDigits(dialCode);
|
||||
const isExplicitInternational = isExplicitInternationalPhoneInput(value);
|
||||
if (!digits) {
|
||||
return '';
|
||||
}
|
||||
if (normalizedDialCode && digits.startsWith(normalizedDialCode) && digits.length > normalizedDialCode.length) {
|
||||
return digits.slice(normalizedDialCode.length);
|
||||
}
|
||||
if (isExplicitInternational) {
|
||||
return digits;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function toE164PhoneNumber(value, dialCode) {
|
||||
const digits = normalizePhoneDigits(value);
|
||||
const normalizedDialCode = normalizePhoneDigits(dialCode);
|
||||
const isExplicitInternational = isExplicitInternationalPhoneInput(value);
|
||||
if (!digits) {
|
||||
return '';
|
||||
}
|
||||
if (isExplicitInternational) {
|
||||
return `+${digits}`;
|
||||
}
|
||||
if (!normalizedDialCode) {
|
||||
return digits.startsWith('+') ? digits : `+${digits}`;
|
||||
return `+${digits}`;
|
||||
}
|
||||
if (digits.startsWith(normalizedDialCode)) {
|
||||
return `+${digits}`;
|
||||
@@ -234,34 +254,71 @@
|
||||
.map((label) => normalizeCountryLabel(label))
|
||||
.filter(Boolean);
|
||||
return normalizedLabels.some((optionLabel) => (
|
||||
optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)
|
||||
optionLabel.length > 2
|
||||
&& normalizedTarget.length > 2
|
||||
&& (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel))
|
||||
));
|
||||
})
|
||||
|| null;
|
||||
}
|
||||
|
||||
async function ensureCountrySelected(countryLabel) {
|
||||
function findCountryOptionByPhoneNumber(phoneNumber) {
|
||||
const select = getCountrySelect();
|
||||
if (!select) {
|
||||
return null;
|
||||
}
|
||||
const digits = normalizePhoneDigits(phoneNumber);
|
||||
if (!digits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestMatch = null;
|
||||
let bestDialCodeLength = 0;
|
||||
for (const option of Array.from(select.options || [])) {
|
||||
const dialCode = normalizePhoneDigits(extractDialCodeFromText(getOptionLabel(option)));
|
||||
if (!dialCode || !digits.startsWith(dialCode)) {
|
||||
continue;
|
||||
}
|
||||
if (dialCode.length > bestDialCodeLength) {
|
||||
bestMatch = option;
|
||||
bestDialCodeLength = dialCode.length;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
async function trySelectCountryOption(select, targetOption) {
|
||||
if (!select || !targetOption) {
|
||||
return false;
|
||||
}
|
||||
const selectedOption = getSelectedCountryOption();
|
||||
if (selectedOption && isSameCountryOption(selectedOption, targetOption)) {
|
||||
return true;
|
||||
}
|
||||
select.value = String(targetOption.value || '');
|
||||
dispatchInputEvents(select);
|
||||
await sleep(250);
|
||||
const nextSelectedOption = getSelectedCountryOption();
|
||||
return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption));
|
||||
}
|
||||
|
||||
async function ensureCountrySelected(countryLabel, phoneNumber = '') {
|
||||
const select = getCountrySelect();
|
||||
if (!select) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetOption = findCountryOptionByLabel(countryLabel);
|
||||
if (!targetOption) {
|
||||
throw new Error(`Add-phone page is missing the country option for "${countryLabel}".`);
|
||||
}
|
||||
|
||||
const selectedOption = getSelectedCountryOption();
|
||||
if (selectedOption && isSameCountryOption(selectedOption, targetOption)) {
|
||||
const byLabel = findCountryOptionByLabel(countryLabel);
|
||||
if (await trySelectCountryOption(select, byLabel)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
select.value = String(targetOption.value || '');
|
||||
dispatchInputEvents(select);
|
||||
await sleep(250);
|
||||
const byPhoneNumber = findCountryOptionByPhoneNumber(phoneNumber);
|
||||
if (await trySelectCountryOption(select, byPhoneNumber)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextSelectedOption = getSelectedCountryOption();
|
||||
return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption));
|
||||
return Boolean(getSelectedCountryOption());
|
||||
}
|
||||
|
||||
function getAddPhoneSubmitButton() {
|
||||
@@ -398,6 +455,81 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function getAuthRetryButton(options = {}) {
|
||||
const { allowDisabled = false } = options;
|
||||
const direct = document.querySelector('button[data-dd-action-name="Try again"]');
|
||||
if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const candidates = document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]');
|
||||
return Array.from(candidates).find((element) => {
|
||||
if (!isVisibleElement(element) || (!allowDisabled && !isActionEnabled(element))) {
|
||||
return false;
|
||||
}
|
||||
const text = String(getActionText?.(element) || '').trim();
|
||||
return /重试|try\s+again/i.test(text);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function is405MethodNotAllowedPage() {
|
||||
const path = String(location?.pathname || '');
|
||||
if (!/\/phone-verification(?:[/?#]|$)/i.test(path) && !/\/add-phone(?:[/?#]|$)/i.test(path)) {
|
||||
return false;
|
||||
}
|
||||
const text = String(getPageTextSnapshot?.() || '').replace(/\s+/g, ' ').trim();
|
||||
const title = String(document?.title || '');
|
||||
const matched = PHONE_ROUTE_405_PATTERN.test(text) || PHONE_ROUTE_405_PATTERN.test(title);
|
||||
if (!matched) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(getAuthRetryButton({ allowDisabled: true }));
|
||||
}
|
||||
|
||||
async function recoverPhoneRoute405(timeout = 12000, options = {}) {
|
||||
const now = Date.now();
|
||||
if (
|
||||
lastPhoneRoute405RecoveryFailedAt > 0
|
||||
&& now - lastPhoneRoute405RecoveryFailedAt < PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS
|
||||
) {
|
||||
throw new Error(
|
||||
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route is still in 405 recovery cooldown (${Math.ceil((PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS - (now - lastPhoneRoute405RecoveryFailedAt)) / 1000)}s left). URL: ${location.href}`
|
||||
);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
let clicked = 0;
|
||||
const maxRetryClicks = Math.max(
|
||||
1,
|
||||
Math.floor(Number(options?.maxRetryClicks) || PHONE_ROUTE_405_MAX_RECOVERY_CLICKS)
|
||||
);
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
if (!is405MethodNotAllowedPage()) {
|
||||
return;
|
||||
}
|
||||
const retryButton = getAuthRetryButton({ allowDisabled: true });
|
||||
if (retryButton && isActionEnabled(retryButton)) {
|
||||
if (clicked >= maxRetryClicks) {
|
||||
lastPhoneRoute405RecoveryFailedAt = Date.now();
|
||||
throw new Error(
|
||||
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route stayed on 405 after ${clicked} retry click(s). URL: ${location.href}`
|
||||
);
|
||||
}
|
||||
clicked += 1;
|
||||
await humanPause(200, 500);
|
||||
simulateClick(retryButton);
|
||||
await sleep(1000);
|
||||
continue;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
lastPhoneRoute405RecoveryFailedAt = Date.now();
|
||||
throw new Error(
|
||||
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route 405 recovery timed out after ${clicked} retry click(s). URL: ${location.href}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForAddPhoneReady(timeout = 20000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
@@ -414,6 +546,10 @@
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
if (is405MethodNotAllowedPage()) {
|
||||
await recoverPhoneRoute405(Math.min(12000, Math.max(1000, timeout - (Date.now() - start))));
|
||||
continue;
|
||||
}
|
||||
if (isPhoneVerificationPageReady()) {
|
||||
return {
|
||||
phoneVerificationPage: true,
|
||||
@@ -448,18 +584,15 @@
|
||||
|
||||
async function submitPhoneNumber(payload = {}) {
|
||||
const countryLabel = String(payload.countryLabel || '').trim();
|
||||
if (!countryLabel) {
|
||||
throw new Error('Missing country label for add-phone submission.');
|
||||
}
|
||||
|
||||
const isExplicitInternational = isExplicitInternationalPhoneInput(payload.phoneNumber);
|
||||
await waitForAddPhoneReady();
|
||||
const countrySelected = await ensureCountrySelected(countryLabel);
|
||||
const countrySelected = await ensureCountrySelected(countryLabel, payload.phoneNumber);
|
||||
if (!countrySelected) {
|
||||
throw new Error(`Failed to select "${countryLabel}" on the add-phone page.`);
|
||||
throw new Error(`Failed to select "${countryLabel || 'target country'}" on the add-phone page.`);
|
||||
}
|
||||
|
||||
const dialCode = getDisplayedDialCode();
|
||||
if (!dialCode) {
|
||||
if (!dialCode && !isExplicitInternational) {
|
||||
throw new Error(`Could not determine the dial code for "${countryLabel}" on the add-phone page.`);
|
||||
}
|
||||
|
||||
@@ -498,6 +631,10 @@
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
if (is405MethodNotAllowedPage()) {
|
||||
await recoverPhoneRoute405(Math.min(12000, Math.max(1000, timeout - (Date.now() - start))));
|
||||
continue;
|
||||
}
|
||||
|
||||
const errorText = getVerificationErrorText();
|
||||
if (errorText) {
|
||||
@@ -565,40 +702,81 @@
|
||||
fillInput(codeInput, code);
|
||||
await sleep(250);
|
||||
simulateClick(submitButton);
|
||||
if (is405MethodNotAllowedPage()) {
|
||||
await recoverPhoneRoute405(12000);
|
||||
}
|
||||
return waitForPhoneVerificationOutcome();
|
||||
}
|
||||
|
||||
async function resendPhoneVerificationCode(timeout = 45000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
const throttledText = getPhoneResendThrottleText();
|
||||
if (throttledText) {
|
||||
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`);
|
||||
}
|
||||
const resendButton = getPhoneVerificationResendButton({ allowDisabled: true });
|
||||
if (resendButton && isActionEnabled(resendButton)) {
|
||||
await humanPause(250, 700);
|
||||
simulateClick(resendButton);
|
||||
await sleep(1000);
|
||||
const afterClickThrottleText = getPhoneResendThrottleText();
|
||||
if (afterClickThrottleText) {
|
||||
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`);
|
||||
if (activePhoneResendPromise) {
|
||||
return activePhoneResendPromise;
|
||||
}
|
||||
|
||||
activePhoneResendPromise = (async () => {
|
||||
const start = Date.now();
|
||||
const route405RecoveryStart = Date.now();
|
||||
let route405RecoveryCount = 0;
|
||||
const recoverRoute405WithinResend = async () => {
|
||||
route405RecoveryCount += 1;
|
||||
if (route405RecoveryCount > PHONE_RESEND_ROUTE_405_MAX_RECOVERIES) {
|
||||
throw new Error(
|
||||
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification resend stayed on route-405 page after ${PHONE_RESEND_ROUTE_405_MAX_RECOVERIES} recovery round(s). URL: ${location.href}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
resent: true,
|
||||
url: location.href,
|
||||
};
|
||||
const recoveryBudgetLeft = PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS - (Date.now() - route405RecoveryStart);
|
||||
if (recoveryBudgetLeft <= 0) {
|
||||
throw new Error(
|
||||
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification resend exceeded route-405 recovery budget (${PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS}ms). URL: ${location.href}`
|
||||
);
|
||||
}
|
||||
const remainingTimeout = Math.max(1000, timeout - (Date.now() - start));
|
||||
const recoveryTimeout = Math.max(1000, Math.min(12000, recoveryBudgetLeft, remainingTimeout));
|
||||
await recoverPhoneRoute405(recoveryTimeout);
|
||||
};
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
if (is405MethodNotAllowedPage()) {
|
||||
await recoverRoute405WithinResend();
|
||||
continue;
|
||||
}
|
||||
const throttledText = getPhoneResendThrottleText();
|
||||
if (throttledText) {
|
||||
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`);
|
||||
}
|
||||
const resendButton = getPhoneVerificationResendButton({ allowDisabled: true });
|
||||
if (resendButton && isActionEnabled(resendButton)) {
|
||||
await humanPause(250, 700);
|
||||
simulateClick(resendButton);
|
||||
await sleep(1000);
|
||||
if (is405MethodNotAllowedPage()) {
|
||||
await recoverRoute405WithinResend();
|
||||
continue;
|
||||
}
|
||||
const afterClickThrottleText = getPhoneResendThrottleText();
|
||||
if (afterClickThrottleText) {
|
||||
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`);
|
||||
}
|
||||
return {
|
||||
resent: true,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
const timeoutThrottleText = getPhoneResendThrottleText();
|
||||
if (timeoutThrottleText) {
|
||||
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`);
|
||||
}
|
||||
const timeoutThrottleText = getPhoneResendThrottleText();
|
||||
if (timeoutThrottleText) {
|
||||
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for the phone verification resend button.');
|
||||
throw new Error('Timed out waiting for the phone verification resend button.');
|
||||
})().finally(() => {
|
||||
activePhoneResendPromise = null;
|
||||
});
|
||||
|
||||
return activePhoneResendPromise;
|
||||
}
|
||||
|
||||
async function returnToAddPhone(timeout = 20000) {
|
||||
|
||||
+104
-11
@@ -1036,8 +1036,8 @@ const CONTINUE_ACTION_PATTERN = /继续|continue/i;
|
||||
const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手机号|phone\s+number|telephone/i;
|
||||
const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试|unable\s+to\s+create\s+(?:your\s+)?account|couldn'?t\s+create\s+(?:your\s+)?account|something\s+went\s+wrong|invalid\s+(?:birthday|birth|date)|生日|出生日期/i;
|
||||
const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i;
|
||||
const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时/i;
|
||||
const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405/i;
|
||||
const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时|failed\s+to\s+fetch|network\s+error|fetch\s+failed/i;
|
||||
const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i;
|
||||
const SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX = 'SIGNUP_USER_ALREADY_EXISTS::';
|
||||
const SIGNUP_EMAIL_EXISTS_PATTERN = /与此电子邮件地址相关联的帐户已存在|account\s+associated\s+with\s+this\s+email\s+address\s+already\s+exists|email\s+address.*already\s+exists/i;
|
||||
|
||||
@@ -1144,7 +1144,14 @@ function isLikelyLoggedInChatgptHomeUrl(rawUrl = location.href) {
|
||||
}
|
||||
}
|
||||
|
||||
function getStep4PostVerificationState() {
|
||||
function getStep4PostVerificationState(options = {}) {
|
||||
const { ignoreVerificationVisibility = false } = options;
|
||||
// Newer auth flows can briefly render profile fields before the email-verification
|
||||
// form fully exits. Do not advance to Step 5 while verification UI is still present.
|
||||
if (!ignoreVerificationVisibility && isVerificationPageStillVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isStep5Ready() || isSignupProfilePageUrl()) {
|
||||
return {
|
||||
state: 'step5',
|
||||
@@ -1536,10 +1543,11 @@ function getAuthTimeoutErrorPageState(options = {}) {
|
||||
|| AUTH_TIMEOUT_ERROR_TITLE_PATTERN.test(document.title || '');
|
||||
const detailMatched = AUTH_TIMEOUT_ERROR_DETAIL_PATTERN.test(text);
|
||||
const routeErrorMatched = AUTH_ROUTE_ERROR_PATTERN.test(text);
|
||||
const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text);
|
||||
const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text);
|
||||
const userAlreadyExistsBlocked = /user_already_exists/i.test(text);
|
||||
|
||||
if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
|
||||
if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1551,6 +1559,7 @@ function getAuthTimeoutErrorPageState(options = {}) {
|
||||
titleMatched,
|
||||
detailMatched,
|
||||
routeErrorMatched,
|
||||
fetchFailedMatched,
|
||||
maxCheckAttemptsBlocked,
|
||||
userAlreadyExistsBlocked,
|
||||
};
|
||||
@@ -2382,7 +2391,7 @@ async function waitForVerificationSubmitOutcome(step, timeout) {
|
||||
}
|
||||
|
||||
if (step === 4) {
|
||||
const postVerificationState = getStep4PostVerificationState();
|
||||
const postVerificationState = getStep4PostVerificationState({ ignoreVerificationVisibility: true });
|
||||
if (postVerificationState?.state === 'logged_in_home') {
|
||||
return {
|
||||
success: true,
|
||||
@@ -2417,7 +2426,7 @@ async function waitForVerificationSubmitOutcome(step, timeout) {
|
||||
throw createSignupUserAlreadyExistsError();
|
||||
}
|
||||
|
||||
const postVerificationState = getStep4PostVerificationState();
|
||||
const postVerificationState = getStep4PostVerificationState({ ignoreVerificationVisibility: true });
|
||||
if (postVerificationState?.state === 'logged_in_home') {
|
||||
return {
|
||||
success: true,
|
||||
@@ -2531,12 +2540,25 @@ async function waitForSplitVerificationInputsFilled(inputs, code, timeout = 2500
|
||||
}
|
||||
|
||||
async function fillVerificationCode(step, payload) {
|
||||
const { code } = payload;
|
||||
const { code, signupProfile } = payload;
|
||||
if (!code) throw new Error('未提供验证码。');
|
||||
|
||||
if (step === 4 && isStep5Ready()) {
|
||||
log(`步骤 ${step}:检测到页面已进入下一阶段,本次验证码提交按成功处理。`, 'ok');
|
||||
return { success: true, assumed: true, alreadyAdvanced: true };
|
||||
if (step === 4) {
|
||||
const postVerificationState = getStep4PostVerificationState();
|
||||
if (postVerificationState?.state === 'logged_in_home') {
|
||||
log(`步骤 ${step}:检测到页面已进入 ChatGPT 已登录态,本次验证码提交按成功处理。`, 'ok');
|
||||
return {
|
||||
success: true,
|
||||
assumed: true,
|
||||
alreadyAdvanced: true,
|
||||
skipProfileStep: true,
|
||||
url: postVerificationState.url || location.href,
|
||||
};
|
||||
}
|
||||
if (postVerificationState?.state === 'step5') {
|
||||
log(`步骤 ${step}:检测到页面已进入下一阶段,本次验证码提交按成功处理。`, 'ok');
|
||||
return { success: true, assumed: true, alreadyAdvanced: true };
|
||||
}
|
||||
}
|
||||
if (step === 8) {
|
||||
if (isStep8Ready()) {
|
||||
@@ -2554,6 +2576,18 @@ async function fillVerificationCode(step, payload) {
|
||||
await waitForLoginVerificationPageReady();
|
||||
}
|
||||
|
||||
const combinedSignupProfilePage = step === 4
|
||||
&& await waitForCombinedSignupVerificationProfilePage();
|
||||
if (combinedSignupProfilePage) {
|
||||
if (!signupProfile || !signupProfile.firstName || !signupProfile.lastName) {
|
||||
throw new Error('当前注册验证码页面要求同时填写资料,但未提供姓名或生日数据。');
|
||||
}
|
||||
await step5_fillNameBirthday({
|
||||
...signupProfile,
|
||||
prefillOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Find code input — could be a single input or multiple separate inputs
|
||||
// Retry with 405 error recovery if needed
|
||||
const maxRetries = 3;
|
||||
@@ -2632,6 +2666,10 @@ async function fillVerificationCode(step, payload) {
|
||||
} else {
|
||||
log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok');
|
||||
}
|
||||
if (combinedSignupProfilePage && !outcome.invalidCode) {
|
||||
outcome.skipProfileStep = true;
|
||||
outcome.skipProfileStepReason = 'combined_verification_profile';
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
@@ -2663,6 +2701,11 @@ async function fillVerificationCode(step, payload) {
|
||||
log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok');
|
||||
}
|
||||
|
||||
if (combinedSignupProfilePage && !outcome.invalidCode) {
|
||||
outcome.skipProfileStep = true;
|
||||
outcome.skipProfileStepReason = 'combined_verification_profile';
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
@@ -3333,8 +3376,53 @@ function getStep5DirectCompletionPayload({ isAgeMode = false } = {}) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function isCombinedSignupVerificationProfilePage() {
|
||||
if (!isEmailVerificationPage() || !isVerificationPageStillVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!document.querySelector('form[action*="email-verification/register" i]')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameInput = document.querySelector('input[name="name"], input[autocomplete="name"]');
|
||||
if (!nameInput || !isVisibleElement(nameInput)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ageInput = document.querySelector('input[name="age"]');
|
||||
if (ageInput && isVisibleElement(ageInput)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
|
||||
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
|
||||
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
|
||||
return Boolean(
|
||||
yearSpinner
|
||||
&& monthSpinner
|
||||
&& daySpinner
|
||||
&& isVisibleElement(yearSpinner)
|
||||
&& isVisibleElement(monthSpinner)
|
||||
&& isVisibleElement(daySpinner)
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForCombinedSignupVerificationProfilePage(timeout = 2500) {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
if (isCombinedSignupVerificationProfilePage()) {
|
||||
return true;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
return isCombinedSignupVerificationProfilePage();
|
||||
}
|
||||
|
||||
async function step5_fillNameBirthday(payload) {
|
||||
const { firstName, lastName, age, year, month, day } = payload;
|
||||
const { firstName, lastName, age, year, month, day, prefillOnly = false } = payload;
|
||||
if (!firstName || !lastName) throw new Error('未提供姓名数据。');
|
||||
|
||||
const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null);
|
||||
@@ -3533,6 +3621,11 @@ async function step5_fillNameBirthday(payload) {
|
||||
}
|
||||
|
||||
|
||||
if (prefillOnly) {
|
||||
log('步骤 4:混合注册页资料已预填,继续填写验证码。', 'info');
|
||||
return { prefilled: true };
|
||||
}
|
||||
|
||||
// Click "完成帐户创建" button
|
||||
await sleep(500);
|
||||
const completeBtn = document.querySelector('button[type="submit"]')
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 错误重试分层策略(步骤 4/8/9/自动运行)
|
||||
|
||||
本文档说明当前实现中的三层重试语义,避免把“当步重试 / 小循环重试 / 大循环重试”混在一起理解。
|
||||
|
||||
## 1. 第一层:当步重试(Step 内部)
|
||||
|
||||
定义:不离开当前步骤,在当前页面链路内恢复或替换资源。
|
||||
|
||||
典型位置:
|
||||
- `background/steps/fetch-signup-code.js`(步骤 4 页面恢复)
|
||||
- `background/steps/fetch-login-code.js` + `background/verification-flow.js`(步骤 8 邮箱轮询与重发)
|
||||
- `background/phone-verification-flow.js`(步骤 9 号码轮换、重发、回到 add-phone)
|
||||
|
||||
常见动作:
|
||||
- 点击 `Try again` 恢复 405/重试页;
|
||||
- 同一步内重新拉验证码;
|
||||
- 步骤 9 内直接换号(不立刻回步骤 7);
|
||||
- 同提供商/同国家内换价格档位或换号码。
|
||||
|
||||
## 2. 第二层:小循环重试(授权链路重开)
|
||||
|
||||
定义:当前轮次内回到授权链路起点(通常步骤 7),再走 7→8→9。
|
||||
|
||||
触发来源:
|
||||
- `background.js` 的 `getPostStep6AutoRestartDecision(...)`;
|
||||
- 步骤 8 检测到邮箱通信异常且当前链路连续重试超限;
|
||||
- 步骤 9 触发 `PHONE_RESTART_STEP7` 类错误(例如回调窗口过期/链路不可恢复)。
|
||||
|
||||
特点:
|
||||
- 还是同一“轮次”;
|
||||
- 不进入线程间隔(20 分钟);
|
||||
- 优先用于“流程状态错位/页面链路失效”类问题。
|
||||
|
||||
## 3. 第三层:大循环重试(自动运行 attempt/round)
|
||||
|
||||
定义:由自动运行控制器决定是否进入下一次 attempt,或等待线程间隔后继续。
|
||||
|
||||
位置:
|
||||
- `background/auto-run-controller.js`
|
||||
|
||||
关键点:
|
||||
- `autoRunSkipFailures=true` 时,允许同轮多次 attempt;
|
||||
- 某些错误会被视为“本轮直接失败并跳过剩余重试”;
|
||||
- 若配置了线程间隔,会出现“等待 N 分钟后第 x 次尝试”日志。
|
||||
|
||||
## 4. 错误分类优先级(当前实现)
|
||||
|
||||
1) 先做当步恢复(能在步骤内闭环就不升级)
|
||||
2) 当步失败后,再判断是否走小循环(回步骤 7)
|
||||
3) 小循环仍失败,才交给大循环(attempt / round / 线程间隔)
|
||||
|
||||
## 5. 已落实的关键规则(与近期问题对应)
|
||||
|
||||
- `Failed to fetch / network error / fetch failed`:
|
||||
步骤 4/8/9 进入有上限的网络重试(当前默认 3 次,冷却 12 秒)。
|
||||
|
||||
- `Step 9: 5sim check activation failed: order not found`:
|
||||
归类为“号码失效”,在步骤 9 内立即换号(当步重试),不应直接触发线程间隔。
|
||||
|
||||
- `Route Error 405`(手机号验证码页):
|
||||
优先当步恢复;若落入 route-405 循环,步骤 9 内换号。
|
||||
|
||||
- `add-phone / phone-verification` 页面状态:
|
||||
表示流程已进入手机链路,自动运行层默认不再盲目回步骤 7,避免破坏当前链路。
|
||||
|
||||
## 6. 关于“第几轮尝试 + 暂停”含义
|
||||
|
||||
- “第 x/y 轮第 n 次尝试”是自动运行控制器的外层 attempt 计数;
|
||||
- “线程间隔:等待 20 分钟...”表示已经升级到第三层(大循环);
|
||||
- 用户手动停止会中断当前计划;恢复时是否回到步骤 7,取决于当前页面状态与回调是否过期:
|
||||
- 回调未过期且仍在可恢复链路:优先当前链路继续;
|
||||
- 回调已过期或链路断裂:回步骤 7 重拉授权。
|
||||
@@ -13,7 +13,10 @@ function normalizeIpProxyService(value = '') {
|
||||
const ipProxyActionState = {
|
||||
busy: false,
|
||||
action: '',
|
||||
startedAt: 0,
|
||||
};
|
||||
const IP_PROXY_ACTION_LOCK_TIMEOUT_MS = 25000;
|
||||
let ipProxyDeferredProbeTimer = 0;
|
||||
const IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded';
|
||||
let ipProxySectionExpanded = false;
|
||||
|
||||
@@ -78,12 +81,14 @@ function getIpProxyActionState() {
|
||||
return {
|
||||
busy: Boolean(ipProxyActionState.busy),
|
||||
action: normalizeIpProxyActionType(ipProxyActionState.action),
|
||||
startedAt: Number(ipProxyActionState.startedAt) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function setIpProxyActionBusy(action = '', busy = false) {
|
||||
ipProxyActionState.busy = Boolean(busy);
|
||||
ipProxyActionState.action = ipProxyActionState.busy ? normalizeIpProxyActionType(action) : '';
|
||||
ipProxyActionState.startedAt = ipProxyActionState.busy ? Date.now() : 0;
|
||||
}
|
||||
|
||||
async function runIpProxyActionWithLock(action = '', runner) {
|
||||
@@ -104,10 +109,23 @@ async function runIpProxyActionWithLock(action = '', runner) {
|
||||
updateIpProxyUI(latestState);
|
||||
}
|
||||
|
||||
const actionLabel = getIpProxyActionLabel(nextAction);
|
||||
const timeoutMs = Math.max(5000, Number(IP_PROXY_ACTION_LOCK_TIMEOUT_MS) || 25000);
|
||||
let timeoutId = 0;
|
||||
try {
|
||||
const value = await runner();
|
||||
const value = await Promise.race([
|
||||
Promise.resolve().then(() => runner()),
|
||||
new Promise((_, reject) => {
|
||||
timeoutId = globalThis.setTimeout(() => {
|
||||
reject(new Error(`${actionLabel}超时(${Math.round(timeoutMs / 1000)} 秒),已自动解锁,请重试。`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
return { skipped: false, value };
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
}
|
||||
setIpProxyActionBusy(nextAction, false);
|
||||
if (typeof updateIpProxyUI === 'function') {
|
||||
updateIpProxyUI(latestState);
|
||||
@@ -115,6 +133,24 @@ async function runIpProxyActionWithLock(action = '', runner) {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleIpProxyExitProbe(options = {}) {
|
||||
const {
|
||||
silent = true,
|
||||
delayMs = 80,
|
||||
} = options;
|
||||
const delay = Math.max(0, Number(delayMs) || 0);
|
||||
if (ipProxyDeferredProbeTimer) {
|
||||
globalThis.clearTimeout(ipProxyDeferredProbeTimer);
|
||||
ipProxyDeferredProbeTimer = 0;
|
||||
}
|
||||
ipProxyDeferredProbeTimer = globalThis.setTimeout(() => {
|
||||
ipProxyDeferredProbeTimer = 0;
|
||||
Promise.resolve()
|
||||
.then(() => probeIpProxyExit({ silent }))
|
||||
.catch(() => {});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function normalizeIpProxyMode(value = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return SUPPORTED_IP_PROXY_MODES.includes(normalized) ? normalized : DEFAULT_IP_PROXY_MODE;
|
||||
@@ -210,6 +246,8 @@ function normalizeIpProxyServiceProfile(rawValue = {}) {
|
||||
accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''),
|
||||
accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''),
|
||||
poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20),
|
||||
autoSyncEnabled: Boolean(raw.autoSyncEnabled),
|
||||
autoSyncIntervalMinutes: String(Math.max(1, Math.min(1440, Number.parseInt(String(raw.autoSyncIntervalMinutes ?? '').trim(), 10) || 15))),
|
||||
host: String(raw.host || '').trim(),
|
||||
port: String(normalizeIpProxyPort(raw.port || '') || ''),
|
||||
protocol: normalizeIpProxyProtocol(raw.protocol),
|
||||
@@ -227,6 +265,8 @@ function buildIpProxyServiceProfileFromFlatState(state = {}) {
|
||||
accountSessionPrefix: state?.ipProxyAccountSessionPrefix,
|
||||
accountLifeMinutes: state?.ipProxyAccountLifeMinutes,
|
||||
poolTargetCount: state?.ipProxyPoolTargetCount,
|
||||
autoSyncEnabled: state?.ipProxyAutoSyncEnabled,
|
||||
autoSyncIntervalMinutes: state?.ipProxyAutoSyncIntervalMinutes,
|
||||
host: state?.ipProxyHost,
|
||||
port: state?.ipProxyPort,
|
||||
protocol: state?.ipProxyProtocol,
|
||||
@@ -387,6 +427,8 @@ function buildCurrentIpProxyServiceProfileFromInputs() {
|
||||
accountSessionPrefix: inputIpProxyAccountSessionPrefix?.value || '',
|
||||
accountLifeMinutes: inputIpProxyAccountLifeMinutes?.value || '',
|
||||
poolTargetCount: inputIpProxyPoolTargetCount?.value || '',
|
||||
autoSyncEnabled: Boolean(inputIpProxyAutoSyncEnabled?.checked),
|
||||
autoSyncIntervalMinutes: inputIpProxyAutoSyncIntervalMinutes?.value || '',
|
||||
host: inputIpProxyHost?.value || '',
|
||||
port: inputIpProxyPort?.value || '',
|
||||
protocol: selectIpProxyProtocol?.value || '',
|
||||
@@ -419,6 +461,8 @@ function buildIpProxyStatePatchFromServiceProfile(service = '', profile = {}) {
|
||||
ipProxyAccountSessionPrefix: normalizedProfile.accountSessionPrefix,
|
||||
ipProxyAccountLifeMinutes: normalizedProfile.accountLifeMinutes,
|
||||
ipProxyPoolTargetCount: normalizedProfile.poolTargetCount,
|
||||
ipProxyAutoSyncEnabled: normalizedProfile.autoSyncEnabled,
|
||||
ipProxyAutoSyncIntervalMinutes: Number.parseInt(String(normalizedProfile.autoSyncIntervalMinutes || '15').trim(), 10) || 15,
|
||||
ipProxyHost: normalizedProfile.host,
|
||||
ipProxyPort: normalizedProfile.port,
|
||||
ipProxyProtocol: normalizedProfile.protocol,
|
||||
@@ -449,6 +493,12 @@ function applyIpProxyServiceProfileToInputs(profile = {}, options = {}) {
|
||||
if (inputIpProxyPoolTargetCount) {
|
||||
inputIpProxyPoolTargetCount.value = normalizedProfile.poolTargetCount;
|
||||
}
|
||||
if (inputIpProxyAutoSyncEnabled) {
|
||||
inputIpProxyAutoSyncEnabled.checked = Boolean(normalizedProfile.autoSyncEnabled);
|
||||
}
|
||||
if (inputIpProxyAutoSyncIntervalMinutes) {
|
||||
inputIpProxyAutoSyncIntervalMinutes.value = String(normalizedProfile.autoSyncIntervalMinutes || '15');
|
||||
}
|
||||
if (inputIpProxyHost) {
|
||||
inputIpProxyHost.value = normalizedProfile.host;
|
||||
}
|
||||
@@ -1058,6 +1108,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
|
||||
stateClass: 'state-idle',
|
||||
text: '未启用,沿用浏览器默认/全局代理。',
|
||||
details: '',
|
||||
hideCurrentDisplay: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1073,6 +1124,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
|
||||
stateClass: warningText ? 'state-warning' : 'state-applied',
|
||||
text: `${statusText}${briefWarning}`,
|
||||
details,
|
||||
hideCurrentDisplay: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1082,6 +1134,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
|
||||
stateClass: 'state-warning',
|
||||
text: '已启用,但当前没有可用代理(已阻断直连)。',
|
||||
details: errorText,
|
||||
hideCurrentDisplay: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -1090,6 +1143,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
|
||||
? '已启用,但账号模式没有可用代理。请先填写代理列表,或填写 Host/Port。已阻断所有网站直连。'
|
||||
: '已启用,但当前没有可用代理。请先点击“拉取”获取 IP 列表。已阻断所有网站直连。',
|
||||
details: '',
|
||||
hideCurrentDisplay: false,
|
||||
};
|
||||
}
|
||||
if (reason === 'proxy_api_unavailable') {
|
||||
@@ -1097,6 +1151,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
|
||||
stateClass: 'state-error',
|
||||
text: '已启用,但当前浏览器不支持扩展代理 API,无法应用。',
|
||||
details,
|
||||
hideCurrentDisplay: false,
|
||||
};
|
||||
}
|
||||
if (reason === 'apply_failed') {
|
||||
@@ -1104,6 +1159,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
|
||||
stateClass: 'state-error',
|
||||
text: '已启用,但代理应用失败(已回退默认代理)。',
|
||||
details: errorText || details,
|
||||
hideCurrentDisplay: false,
|
||||
};
|
||||
}
|
||||
if (reason === 'connectivity_failed') {
|
||||
@@ -1112,6 +1168,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
|
||||
stateClass: 'state-error',
|
||||
text: `${prefix};连通性失败,请切换节点或重试。`,
|
||||
details: errorText || details,
|
||||
hideCurrentDisplay: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1121,6 +1178,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
|
||||
? `已启用,等待生效:${endpointWithRegion}${authSuffix}`
|
||||
: '已启用,等待拉取并应用代理。',
|
||||
details,
|
||||
hideCurrentDisplay: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1137,6 +1195,10 @@ function setIpProxyRuntimeStatusDisplay(status = {}) {
|
||||
if (ipProxyRuntimeDot) {
|
||||
ipProxyRuntimeDot.title = text;
|
||||
}
|
||||
const runtimeMeta = ipProxyCurrent?.closest?.('.ip-proxy-runtime-meta') || null;
|
||||
if (runtimeMeta) {
|
||||
runtimeMeta.style.display = status?.hideCurrentDisplay ? 'none' : '';
|
||||
}
|
||||
const detailsText = String(status?.details || '').trim();
|
||||
if (ipProxyRuntimeDetails && ipProxyRuntimeDetailsText) {
|
||||
if (detailsText) {
|
||||
@@ -1241,6 +1303,12 @@ function updateIpProxyUI(state = latestState) {
|
||||
if (rowIpProxyPoolTargetCount) {
|
||||
rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none';
|
||||
}
|
||||
if (rowIpProxyAutoSyncEnabled) {
|
||||
rowIpProxyAutoSyncEnabled.style.display = showSettings ? '' : 'none';
|
||||
}
|
||||
if (rowIpProxyAutoSyncInterval) {
|
||||
rowIpProxyAutoSyncInterval.style.display = showSettings ? '' : 'none';
|
||||
}
|
||||
if (rowIpProxyHost) {
|
||||
rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none';
|
||||
}
|
||||
@@ -1334,6 +1402,16 @@ function updateIpProxyUI(state = latestState) {
|
||||
if (inputIpProxyAccountList) {
|
||||
inputIpProxyAccountList.disabled = !enabled || !isAccountMode || !accountListAvailable;
|
||||
}
|
||||
if (inputIpProxyAutoSyncEnabled) {
|
||||
inputIpProxyAutoSyncEnabled.disabled = !enabled;
|
||||
}
|
||||
if (inputIpProxyAutoSyncIntervalMinutes) {
|
||||
const autoSyncEnabled = Boolean(inputIpProxyAutoSyncEnabled?.checked);
|
||||
if (!Number.isFinite(Number.parseInt(String(inputIpProxyAutoSyncIntervalMinutes.value || '').trim(), 10))) {
|
||||
inputIpProxyAutoSyncIntervalMinutes.value = '15';
|
||||
}
|
||||
inputIpProxyAutoSyncIntervalMinutes.disabled = !enabled || !autoSyncEnabled;
|
||||
}
|
||||
|
||||
const runtimeStatus = formatIpProxyRuntimeStatus(runtimeState);
|
||||
setIpProxyRuntimeStatusDisplay(runtimeStatus);
|
||||
@@ -1395,6 +1473,7 @@ async function refreshIpProxyPoolByApi(options = {}) {
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
mode,
|
||||
skipExitProbe: true,
|
||||
},
|
||||
});
|
||||
if (response?.error) {
|
||||
@@ -1425,7 +1504,7 @@ async function refreshIpProxyPoolByApi(options = {}) {
|
||||
syncLatestState(patch);
|
||||
}
|
||||
updateIpProxyUI(latestState);
|
||||
await probeIpProxyExit({ silent: true }).catch(() => {});
|
||||
scheduleIpProxyExitProbe({ silent: true });
|
||||
|
||||
if (!silent) {
|
||||
if (mode === 'account') {
|
||||
@@ -1447,6 +1526,7 @@ async function switchIpProxyToNext(options = {}) {
|
||||
direction: 'next',
|
||||
mode,
|
||||
forceRefresh: false,
|
||||
skipExitProbe: true,
|
||||
},
|
||||
});
|
||||
if (response?.error) {
|
||||
@@ -1476,7 +1556,7 @@ async function switchIpProxyToNext(options = {}) {
|
||||
syncLatestState(patch);
|
||||
}
|
||||
updateIpProxyUI(latestState);
|
||||
await probeIpProxyExit({ silent: true }).catch(() => {});
|
||||
scheduleIpProxyExitProbe({ silent: true });
|
||||
if (!silent) {
|
||||
showToast(`已切换代理:${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800);
|
||||
}
|
||||
@@ -1491,6 +1571,7 @@ async function changeIpProxyExitBySession(options = {}) {
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
mode,
|
||||
skipExitProbe: true,
|
||||
},
|
||||
});
|
||||
if (response?.error) {
|
||||
@@ -1520,7 +1601,7 @@ async function changeIpProxyExitBySession(options = {}) {
|
||||
syncLatestState(patch);
|
||||
}
|
||||
updateIpProxyUI(latestState);
|
||||
await probeIpProxyExit({ silent: true }).catch(() => {});
|
||||
scheduleIpProxyExitProbe({ silent: true });
|
||||
if (!silent) {
|
||||
showToast(`已执行 Change:${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800);
|
||||
}
|
||||
|
||||
@@ -1161,6 +1161,59 @@ header {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.country-order-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.country-order-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.country-order-chip-label {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.country-order-remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.country-order-remove:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.country-order-separator {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.data-value.has-value { color: var(--text-primary); }
|
||||
|
||||
.mono {
|
||||
@@ -2114,6 +2167,14 @@ header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-sms-runtime-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 33px;
|
||||
min-height: 33px;
|
||||
}
|
||||
|
||||
.hero-sms-price-preview-stack {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
+479
-325
@@ -420,331 +420,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="phone-verification-section" class="data-card phone-verification-card">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
<span class="section-label">接码设置</span>
|
||||
<span class="data-value">手机号验证与 HeroSMS 获取策略</span>
|
||||
</div>
|
||||
<div id="row-phone-verification-enabled" class="section-mini-actions phone-verification-header-actions">
|
||||
<button id="btn-toggle-phone-verification-section" class="btn btn-ghost btn-xs" type="button"
|
||||
aria-expanded="false" aria-controls="row-phone-verification-fold">展开设置</button>
|
||||
<label class="toggle-switch" for="input-phone-verification-enabled" title="启用或禁用手机号接码流程">
|
||||
<input type="checkbox" id="input-phone-verification-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row phone-verification-fold-row" id="row-phone-verification-fold" style="display:none;">
|
||||
<div id="phone-verification-fold" class="phone-verification-fold">
|
||||
<div class="phone-verification-fold-body">
|
||||
<div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
|
||||
<span class="data-label">同步服务</span>
|
||||
<input type="text" id="input-account-run-history-helper-base-url" class="data-input mono"
|
||||
placeholder="http://127.0.0.1:17373" />
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-platform" style="display:none;">
|
||||
<span class="data-label">接码平台</span>
|
||||
<span id="display-hero-sms-platform" class="data-value mono">HeroSMS / OpenAI / Thailand</span>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-country" style="display:none;">
|
||||
<span class="data-label">国家优先级</span>
|
||||
<div class="data-inline hero-sms-country-stack">
|
||||
<select id="select-hero-sms-country" class="data-input mono" multiple size="6" style="display:none;">
|
||||
<option value="52" selected>Thailand</option>
|
||||
</select>
|
||||
<div class="hero-sms-country-mainline">
|
||||
<div id="hero-sms-country-menu-shell" class="hero-sms-country-menu">
|
||||
<button id="btn-hero-sms-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
Thailand (1/3)
|
||||
</button>
|
||||
<div id="hero-sms-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-country-fallback" style="display:none;">
|
||||
<span class="data-label">生效顺序</span>
|
||||
<div class="data-inline data-value-actions">
|
||||
<span id="display-hero-sms-country-fallback-order" class="data-value data-value-fill mono">Thailand(52)</span>
|
||||
<button id="btn-hero-sms-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-acquire-priority" style="display:none;">
|
||||
<span class="data-label">拿号优先级</span>
|
||||
<select id="select-hero-sms-acquire-priority" class="data-input mono">
|
||||
<option value="country">国家优先(默认)</option>
|
||||
<option value="price">价格优先(同价按国家顺序)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-api-key" style="display:none;">
|
||||
<span class="data-label">接码 API</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-hero-sms-api-key" class="data-input data-input-with-icon mono"
|
||||
placeholder="请输入 HeroSMS API Key" />
|
||||
<button id="btn-toggle-hero-sms-api-key" class="input-icon-btn" type="button"
|
||||
data-password-toggle="input-hero-sms-api-key" data-show-label="显示 HeroSMS API Key"
|
||||
data-hide-label="隐藏 HeroSMS API Key" aria-label="显示 HeroSMS API Key" title="显示 HeroSMS API Key"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-max-price" style="display:none;">
|
||||
<span class="data-label">价格</span>
|
||||
<div class="data-inline hero-sms-price-preview-stack">
|
||||
<div class="hero-sms-price-preview-head">
|
||||
<button id="btn-hero-sms-price-preview" class="btn btn-outline btn-xs data-inline-btn" type="button">查询价格</button>
|
||||
</div>
|
||||
<div id="row-hero-sms-price-tiers" class="hero-sms-price-preview-result" style="display:none;">
|
||||
<span id="display-hero-sms-price-tiers" class="data-value mono hero-sms-price-preview-text">未获取</span>
|
||||
</div>
|
||||
<div class="hero-sms-price-controls-grid">
|
||||
<div class="hero-sms-price-control">
|
||||
<span class="hero-sms-settings-caption">价格上限</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-hero-sms-max-price" class="data-input auto-delay-input mono hero-sms-max-price-input" placeholder="0.12" min="0" step="0.0001" title="接码价格上限;可空(空=自动价格)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-sms-price-control hero-sms-price-control-reuse">
|
||||
<span class="hero-sms-settings-caption">号码复用</span>
|
||||
<div class="setting-controls hero-sms-toggle-controls">
|
||||
<label class="toggle-switch hero-sms-price-reuse-toggle" for="input-hero-sms-reuse-enabled" title="开启后会优先复用未超次数的可用号码">
|
||||
<input type="checkbox" id="input-hero-sms-reuse-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-phone-code-settings-group" style="display:none;">
|
||||
<span class="data-label">接码参数</span>
|
||||
<div class="data-inline hero-sms-settings-grid">
|
||||
<div id="row-phone-verification-resend-count" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">验证码重发</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-verification-resend-count" class="data-input auto-delay-input" value="4"
|
||||
min="0" max="20" step="1" title="自动点击重新发送验证码的次数" />
|
||||
<span class="data-unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-replacement-limit" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">换号上限</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-replacement-limit" class="data-input auto-delay-input" value="3" min="1" max="20" step="1" title="步骤 9 内部允许更换号码的最大次数" />
|
||||
<span class="data-unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-code-wait-seconds" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">验证码限时</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-code-wait-seconds" class="data-input auto-delay-input" value="60" min="15" max="300" step="1" title="每轮等待验证码的秒数" />
|
||||
<span class="data-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-code-timeout-windows" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">超时次数</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-code-timeout-windows" class="data-input auto-delay-input" value="2" min="1" max="10" step="1" title="验证码超时后,最多继续等待几轮再换号" />
|
||||
<span class="data-unit">轮</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-code-poll-interval-seconds" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">轮询间隔</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-code-poll-interval-seconds" class="data-input auto-delay-input" value="5" min="1" max="30" step="1" title="向 HeroSMS 查询验证码状态的间隔秒数" />
|
||||
<span class="data-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-code-poll-max-rounds" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">轮询次数</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-code-poll-max-rounds" class="data-input auto-delay-input" value="4" min="1" max="120" step="1" title="每轮验证码等待窗口最多轮询次数" />
|
||||
<span class="data-unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-runtime-pair" style="display:none;">
|
||||
<span class="data-label">运行状态</span>
|
||||
<div class="data-inline hero-sms-runtime-grid">
|
||||
<div id="row-hero-sms-current-number" class="hero-sms-runtime-cell" style="display:none;">
|
||||
<span class="hero-sms-runtime-key">当前分配</span>
|
||||
<span id="display-hero-sms-current-number" class="data-value mono hero-sms-runtime-value">未分配</span>
|
||||
</div>
|
||||
<div id="row-hero-sms-current-code" class="hero-sms-runtime-cell" style="display:none;">
|
||||
<span class="hero-sms-runtime-key">验证码</span>
|
||||
<span id="display-hero-sms-current-code" class="data-value mono hero-sms-runtime-value">未获取</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ip-proxy-section" class="data-card ip-proxy-card">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
<span class="section-label">IP 代理</span>
|
||||
<span class="data-value">用于浏览器代理接管与出口切换</span>
|
||||
</div>
|
||||
<div id="row-ip-proxy-enabled" class="section-mini-actions ip-proxy-header-actions">
|
||||
<button id="btn-toggle-ip-proxy-section" class="btn btn-ghost btn-xs" type="button"
|
||||
aria-expanded="false" aria-controls="row-ip-proxy-fold">展开设置</button>
|
||||
<label class="toggle-switch" for="input-ip-proxy-enabled" title="启用或禁用 IP 代理接管">
|
||||
<input type="checkbox" id="input-ip-proxy-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-fold-row" id="row-ip-proxy-fold" style="display:none;">
|
||||
<div id="ip-proxy-fold" class="ip-proxy-fold">
|
||||
<div class="ip-proxy-fold-body">
|
||||
<div class="data-row" id="row-ip-proxy-service" style="display:none;">
|
||||
<span class="data-label">代理服务</span>
|
||||
<div class="data-inline">
|
||||
<select id="select-ip-proxy-service" class="data-select" disabled>
|
||||
<option value="711proxy">711Proxy(首版)</option>
|
||||
</select>
|
||||
<button id="btn-ip-proxy-service-login" class="btn btn-outline btn-sm data-inline-btn" type="button">
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-ip-proxy-mode" style="display:none;">
|
||||
<span class="data-label">代理模式</span>
|
||||
<div id="ip-proxy-mode-group" class="choice-group" role="group" aria-label="IP代理模式">
|
||||
<button type="button" class="choice-btn" data-ip-proxy-mode="account">账号密码</button>
|
||||
<button type="button" class="choice-btn" data-ip-proxy-mode="api" disabled title="首版暂未开放">API 拉取(暂未开放)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-layout-row" id="row-ip-proxy-layout" style="display:none;">
|
||||
<div class="ip-proxy-layout" id="ip-proxy-layout">
|
||||
<div class="ip-proxy-column ip-proxy-column-account" id="ip-proxy-account-panel">
|
||||
<div class="ip-proxy-column-header">账号密码模式</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-list" style="display:none;">
|
||||
<span class="data-label">账号代理列表</span>
|
||||
<textarea id="input-ip-proxy-account-list" class="data-textarea mono"
|
||||
placeholder="每行一条:host:port 或 host:port:username:password 例如 global.rotgb.711proxy.com:10000:username:password"></textarea>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-session-prefix" style="display:none;">
|
||||
<span class="data-label">会话(session)</span>
|
||||
<input type="text" id="input-ip-proxy-account-session-prefix" class="data-input mono"
|
||||
placeholder="会话前缀,例如 ZC28qZ0KQL" />
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-life-minutes" style="display:none;">
|
||||
<span class="data-label">时长(life)</span>
|
||||
<div class="data-inline">
|
||||
<input type="number" id="input-ip-proxy-account-life-minutes" class="data-input" min="1" max="1440" step="1"
|
||||
placeholder="5-180(分钟)" title="711Proxy 会话时长范围:5-180 分钟" />
|
||||
<span class="data-unit">分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-host" style="display:none;">
|
||||
<span class="data-label">代理 Host</span>
|
||||
<input type="text" id="input-ip-proxy-host" class="data-input" placeholder="例如 global.rotgb.711proxy.com" />
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-port" style="display:none;">
|
||||
<span class="data-label">代理 Port</span>
|
||||
<input type="number" id="input-ip-proxy-port" class="data-input" min="1" max="65535" step="1"
|
||||
placeholder="例如 10000" />
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-protocol" style="display:none;">
|
||||
<span class="data-label">代理协议</span>
|
||||
<select id="select-ip-proxy-protocol" class="data-select">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="socks4">SOCKS4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-username" style="display:none;">
|
||||
<span class="data-label">代理账号</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-ip-proxy-username" class="data-input data-input-with-icon"
|
||||
placeholder="例如 USER047152-zone-custom-region-US-asn-ASN81" />
|
||||
<button id="btn-toggle-ip-proxy-username" class="input-icon-btn" type="button" aria-label="显示代理账号"
|
||||
title="显示代理账号"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-password" style="display:none;">
|
||||
<span class="data-label">代理密码</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-ip-proxy-password" class="data-input data-input-with-icon"
|
||||
placeholder="账号密码代理的密码" />
|
||||
<button id="btn-toggle-ip-proxy-password" class="input-icon-btn" type="button" aria-label="显示代理密码"
|
||||
title="显示代理密码"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-region" style="display:none;">
|
||||
<span class="data-label">地区参数</span>
|
||||
<input type="text" id="input-ip-proxy-region" class="data-input" placeholder="可选,例如 US / DE / HK" />
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-pool-target-count" style="display:none;">
|
||||
<span class="data-label">任务切换阈值</span>
|
||||
<div class="data-inline">
|
||||
<input type="number" id="input-ip-proxy-pool-target-count" class="data-input" min="1" max="500" step="1"
|
||||
placeholder="20(成功轮次)" title="每成功多少轮任务后自动切换一次代理;仅 1 条节点时会跳过自动切换" />
|
||||
<span class="data-unit">轮</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ip-proxy-column ip-proxy-column-api" id="ip-proxy-api-panel">
|
||||
<div class="ip-proxy-column-header">API 模式</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-api-url" style="display:none;">
|
||||
<span class="data-label">代理API</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-ip-proxy-api-url" class="data-input data-input-with-icon"
|
||||
placeholder="粘贴完整 API 链接" />
|
||||
<button id="btn-toggle-ip-proxy-api-url" class="input-icon-btn" type="button" aria-label="显示代理 API"
|
||||
title="显示代理 API"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-ip-proxy-actions" style="display:none;">
|
||||
<span class="data-label">切换代理</span>
|
||||
<div class="data-inline ip-proxy-actions-inline">
|
||||
<span id="ip-proxy-action-buttons" class="ip-proxy-action-grid">
|
||||
<button id="btn-ip-proxy-refresh" class="btn btn-outline btn-sm data-inline-btn" type="button">拉取</button>
|
||||
<button id="btn-ip-proxy-next" class="btn btn-outline btn-sm data-inline-btn" type="button">下一条</button>
|
||||
<button id="btn-ip-proxy-change" class="btn btn-outline btn-sm data-inline-btn" type="button" title="保持当前会话并刷新出口(仅 711 + session)">Change</button>
|
||||
<button id="btn-ip-proxy-probe" class="btn btn-outline btn-sm data-inline-btn" type="button">检测出口</button>
|
||||
</span>
|
||||
<span id="ip-proxy-action-hint" class="ip-proxy-action-hint">
|
||||
下一条:切到代理池下一条。Change:保持当前 session 重绑链路(仅 711 + session)。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-ip-proxy-runtime-status" style="display:none;">
|
||||
<span class="data-label">代理状态</span>
|
||||
<div id="ip-proxy-runtime-status" class="ip-proxy-runtime-status state-idle">
|
||||
<span id="ip-proxy-runtime-dot" class="ip-proxy-runtime-dot" aria-hidden="true"></span>
|
||||
<div class="ip-proxy-runtime-content">
|
||||
<div id="ip-proxy-runtime-text" class="ip-proxy-runtime-main">未启用,沿用浏览器默认/全局代理。</div>
|
||||
<div class="ip-proxy-runtime-meta">
|
||||
<span id="ip-proxy-current" class="ip-proxy-runtime-current">暂无可用代理</span>
|
||||
</div>
|
||||
<div class="ip-proxy-runtime-details-row">
|
||||
<details id="ip-proxy-runtime-details" class="ip-proxy-runtime-details" hidden>
|
||||
<summary>详情</summary>
|
||||
<div id="ip-proxy-runtime-details-text" class="ip-proxy-runtime-details-text"></div>
|
||||
</details>
|
||||
<button id="btn-ip-proxy-check-ip" class="btn btn-outline btn-xs ip-proxy-check-ip-btn" type="button">检查IP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cloudflare-temp-email-section" class="data-card hotmail-card" style="display:none;">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
@@ -1103,6 +778,485 @@
|
||||
<div id="icloud-list" class="icloud-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ip-proxy-section" class="data-card ip-proxy-card">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
<span class="section-label">IP 代理</span>
|
||||
<span class="data-value">用于浏览器代理接管与出口切换</span>
|
||||
</div>
|
||||
<div id="row-ip-proxy-enabled" class="section-mini-actions ip-proxy-header-actions">
|
||||
<button id="btn-toggle-ip-proxy-section" class="btn btn-ghost btn-xs" type="button"
|
||||
aria-expanded="false" aria-controls="row-ip-proxy-fold">展开设置</button>
|
||||
<label class="toggle-switch" for="input-ip-proxy-enabled" title="启用或禁用 IP 代理接管">
|
||||
<input type="checkbox" id="input-ip-proxy-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-fold-row" id="row-ip-proxy-fold" style="display:none;">
|
||||
<div id="ip-proxy-fold" class="ip-proxy-fold">
|
||||
<div class="ip-proxy-fold-body">
|
||||
<div class="data-row" id="row-ip-proxy-service" style="display:none;">
|
||||
<span class="data-label">代理服务</span>
|
||||
<div class="data-inline">
|
||||
<select id="select-ip-proxy-service" class="data-select" disabled>
|
||||
<option value="711proxy">711Proxy(首版)</option>
|
||||
</select>
|
||||
<button id="btn-ip-proxy-service-login" class="btn btn-outline btn-sm data-inline-btn" type="button">
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-ip-proxy-mode" style="display:none;">
|
||||
<span class="data-label">代理模式</span>
|
||||
<div id="ip-proxy-mode-group" class="choice-group" role="group" aria-label="IP代理模式">
|
||||
<button type="button" class="choice-btn" data-ip-proxy-mode="account">账号密码</button>
|
||||
<button type="button" class="choice-btn" data-ip-proxy-mode="api" disabled title="首版暂未开放">API 拉取(暂未开放)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-layout-row" id="row-ip-proxy-layout" style="display:none;">
|
||||
<div class="ip-proxy-layout" id="ip-proxy-layout">
|
||||
<div class="ip-proxy-column ip-proxy-column-account" id="ip-proxy-account-panel">
|
||||
<div class="ip-proxy-column-header">账号密码模式</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-list" style="display:none;">
|
||||
<span class="data-label">账号代理列表</span>
|
||||
<textarea id="input-ip-proxy-account-list" class="data-textarea mono"
|
||||
placeholder="每行一条:host:port 或 host:port:username:password 例如 global.rotgb.711proxy.com:10000:username:password"></textarea>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-session-prefix" style="display:none;">
|
||||
<span class="data-label">会话(session)</span>
|
||||
<input type="text" id="input-ip-proxy-account-session-prefix" class="data-input mono"
|
||||
placeholder="会话前缀,例如 ZC28qZ0KQL" />
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-life-minutes" style="display:none;">
|
||||
<span class="data-label">时长(life)</span>
|
||||
<div class="data-inline">
|
||||
<input type="number" id="input-ip-proxy-account-life-minutes" class="data-input" min="1" max="1440" step="1"
|
||||
placeholder="5-180(分钟)" title="711Proxy 会话时长范围:5-180 分钟" />
|
||||
<span class="data-unit">分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-host" style="display:none;">
|
||||
<span class="data-label">代理 Host</span>
|
||||
<input type="text" id="input-ip-proxy-host" class="data-input" placeholder="例如 global.rotgb.711proxy.com" />
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-port" style="display:none;">
|
||||
<span class="data-label">代理 Port</span>
|
||||
<input type="number" id="input-ip-proxy-port" class="data-input" min="1" max="65535" step="1"
|
||||
placeholder="例如 10000" />
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-protocol" style="display:none;">
|
||||
<span class="data-label">代理协议</span>
|
||||
<select id="select-ip-proxy-protocol" class="data-select">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="socks4">SOCKS4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-username" style="display:none;">
|
||||
<span class="data-label">代理账号</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-ip-proxy-username" class="data-input data-input-with-icon"
|
||||
placeholder="例如 USER047152-zone-custom-region-US-asn-ASN81" />
|
||||
<button id="btn-toggle-ip-proxy-username" class="input-icon-btn" type="button" aria-label="显示代理账号"
|
||||
title="显示代理账号"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-password" style="display:none;">
|
||||
<span class="data-label">代理密码</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-ip-proxy-password" class="data-input data-input-with-icon"
|
||||
placeholder="账号密码代理的密码" />
|
||||
<button id="btn-toggle-ip-proxy-password" class="input-icon-btn" type="button" aria-label="显示代理密码"
|
||||
title="显示代理密码"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-region" style="display:none;">
|
||||
<span class="data-label">地区参数</span>
|
||||
<input type="text" id="input-ip-proxy-region" class="data-input" placeholder="可选,例如 US / DE / HK" />
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-pool-target-count" style="display:none;">
|
||||
<span class="data-label">任务切换阈值</span>
|
||||
<div class="data-inline">
|
||||
<input type="number" id="input-ip-proxy-pool-target-count" class="data-input" min="1" max="500" step="1"
|
||||
placeholder="20(成功轮次)" title="每成功多少轮任务后自动切换一次代理;仅 1 条节点时会跳过自动切换" />
|
||||
<span class="data-unit">轮</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-auto-sync-enabled" style="display:none;">
|
||||
<span class="data-label">自动同步</span>
|
||||
<div class="data-inline">
|
||||
<label class="toggle-switch" for="input-ip-proxy-auto-sync-enabled" title="启用后会按间隔自动执行一次同步代理">
|
||||
<input type="checkbox" id="input-ip-proxy-auto-sync-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
<span class="data-value">定时刷新代理,降低账号凭据过期导致的中断概率</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-auto-sync-interval" style="display:none;">
|
||||
<span class="data-label">同步间隔</span>
|
||||
<div class="data-inline">
|
||||
<input type="number" id="input-ip-proxy-auto-sync-interval-minutes" class="data-input" min="1" max="1440" step="1"
|
||||
placeholder="15" title="自动同步间隔(分钟)" />
|
||||
<span class="data-unit">分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ip-proxy-column ip-proxy-column-api" id="ip-proxy-api-panel">
|
||||
<div class="ip-proxy-column-header">API 模式</div>
|
||||
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-api-url" style="display:none;">
|
||||
<span class="data-label">代理API</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-ip-proxy-api-url" class="data-input data-input-with-icon"
|
||||
placeholder="粘贴完整 API 链接" />
|
||||
<button id="btn-toggle-ip-proxy-api-url" class="input-icon-btn" type="button" aria-label="显示代理 API"
|
||||
title="显示代理 API"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-ip-proxy-actions" style="display:none;">
|
||||
<span class="data-label">切换代理</span>
|
||||
<div class="data-inline ip-proxy-actions-inline">
|
||||
<span id="ip-proxy-action-buttons" class="ip-proxy-action-grid">
|
||||
<button id="btn-ip-proxy-refresh" class="btn btn-outline btn-sm data-inline-btn" type="button">拉取</button>
|
||||
<button id="btn-ip-proxy-next" class="btn btn-outline btn-sm data-inline-btn" type="button">下一条</button>
|
||||
<button id="btn-ip-proxy-change" class="btn btn-outline btn-sm data-inline-btn" type="button" title="保持当前会话并刷新出口(仅 711 + session)">Change</button>
|
||||
<button id="btn-ip-proxy-probe" class="btn btn-outline btn-sm data-inline-btn" type="button">检测出口</button>
|
||||
</span>
|
||||
<span id="ip-proxy-action-hint" class="ip-proxy-action-hint">
|
||||
下一条:切到代理池下一条。Change:保持当前 session 重绑链路(仅 711 + session)。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-ip-proxy-runtime-status" style="display:none;">
|
||||
<span class="data-label">代理状态</span>
|
||||
<div id="ip-proxy-runtime-status" class="ip-proxy-runtime-status state-idle">
|
||||
<span id="ip-proxy-runtime-dot" class="ip-proxy-runtime-dot" aria-hidden="true"></span>
|
||||
<div class="ip-proxy-runtime-content">
|
||||
<div id="ip-proxy-runtime-text" class="ip-proxy-runtime-main">未启用,沿用浏览器默认/全局代理。</div>
|
||||
<div class="ip-proxy-runtime-meta">
|
||||
<span id="ip-proxy-current" class="ip-proxy-runtime-current">暂无可用代理</span>
|
||||
</div>
|
||||
<div class="ip-proxy-runtime-details-row">
|
||||
<details id="ip-proxy-runtime-details" class="ip-proxy-runtime-details" hidden>
|
||||
<summary>详情</summary>
|
||||
<div id="ip-proxy-runtime-details-text" class="ip-proxy-runtime-details-text"></div>
|
||||
</details>
|
||||
<button id="btn-ip-proxy-check-ip" class="btn btn-outline btn-xs ip-proxy-check-ip-btn" type="button">检查IP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="phone-verification-section" class="data-card phone-verification-card">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
<span class="section-label">接码设置</span>
|
||||
<span class="data-value">手机号验证与接码服务商获取策略</span>
|
||||
</div>
|
||||
<div id="row-phone-verification-enabled" class="section-mini-actions phone-verification-header-actions">
|
||||
<button id="btn-toggle-phone-verification-section" class="btn btn-ghost btn-xs" type="button"
|
||||
aria-expanded="false" aria-controls="row-phone-verification-fold">展开设置</button>
|
||||
<label class="toggle-switch" for="input-phone-verification-enabled" title="启用或禁用手机号接码流程">
|
||||
<input type="checkbox" id="input-phone-verification-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row phone-verification-fold-row" id="row-phone-verification-fold" style="display:none;">
|
||||
<div id="phone-verification-fold" class="phone-verification-fold">
|
||||
<div class="phone-verification-fold-body">
|
||||
<div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
|
||||
<span class="data-label">同步服务</span>
|
||||
<input type="text" id="input-account-run-history-helper-base-url" class="data-input mono"
|
||||
placeholder="http://127.0.0.1:17373" />
|
||||
</div>
|
||||
<div class="data-row" id="row-phone-sms-provider" style="display:none;">
|
||||
<span class="data-label">接码服务商</span>
|
||||
<select id="select-phone-sms-provider" class="data-select mono">
|
||||
<option value="hero-sms">HeroSMS(原有)</option>
|
||||
<option value="5sim">5sim(新增)</option>
|
||||
<option value="nexsms">NexSMS(新增)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row" id="row-phone-sms-provider-order" style="display:none;">
|
||||
<span class="data-label">服务商顺序</span>
|
||||
<div class="data-inline hero-sms-country-stack">
|
||||
<select id="select-phone-sms-provider-order" class="data-input mono" multiple size="3" style="display:none;">
|
||||
<option value="hero-sms" selected>HeroSMS</option>
|
||||
<option value="5sim" selected>5sim</option>
|
||||
<option value="nexsms" selected>NexSMS</option>
|
||||
</select>
|
||||
<div class="hero-sms-country-mainline">
|
||||
<div id="phone-sms-provider-order-menu-shell" class="hero-sms-country-menu">
|
||||
<button id="btn-phone-sms-provider-order-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
HeroSMS / 5sim / NexSMS (3/3)
|
||||
</button>
|
||||
<div id="phone-sms-provider-order-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="data-value hero-sms-country-note">第一位为主服务商,后续按顺序自动回退。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-phone-sms-provider-order-actions" style="display:none;">
|
||||
<span class="data-label">顺序管理</span>
|
||||
<div class="data-inline data-value-actions">
|
||||
<span id="display-phone-sms-provider-order" class="data-value data-value-fill mono country-order-list">未设置</span>
|
||||
<button id="btn-phone-sms-provider-order-reset" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-platform" style="display:none;">
|
||||
<span class="data-label">接码平台</span>
|
||||
<span id="display-hero-sms-platform" class="data-value mono">HeroSMS / OpenAI / Thailand</span>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-country" style="display:none;">
|
||||
<span class="data-label">国家优先级</span>
|
||||
<div class="data-inline hero-sms-country-stack">
|
||||
<select id="select-hero-sms-country" class="data-input mono" multiple size="6" style="display:none;">
|
||||
<option value="52" selected>Thailand</option>
|
||||
</select>
|
||||
<div class="hero-sms-country-mainline">
|
||||
<div id="hero-sms-country-menu-shell" class="hero-sms-country-menu">
|
||||
<button id="btn-hero-sms-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
Thailand (1/3)
|
||||
</button>
|
||||
<div id="hero-sms-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-country-fallback" style="display:none;">
|
||||
<span class="data-label">生效顺序</span>
|
||||
<div class="data-inline data-value-actions">
|
||||
<span id="display-hero-sms-country-fallback-order" class="data-value data-value-fill mono country-order-list">未设置</span>
|
||||
<button id="btn-hero-sms-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-acquire-priority" style="display:none;">
|
||||
<span class="data-label">拿号优先级</span>
|
||||
<select id="select-hero-sms-acquire-priority" class="data-input mono">
|
||||
<option value="country">国家优先(默认)</option>
|
||||
<option value="price">低价优先(同价按国家顺序)</option>
|
||||
<option value="price_high">高价优先(同价按国家顺序)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-api-key" style="display:none;">
|
||||
<span class="data-label">接码 API</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-hero-sms-api-key" class="data-input data-input-with-icon mono"
|
||||
placeholder="请输入 HeroSMS API Key" />
|
||||
<button id="btn-toggle-hero-sms-api-key" class="input-icon-btn" type="button"
|
||||
data-password-toggle="input-hero-sms-api-key" data-show-label="显示 HeroSMS API Key"
|
||||
data-hide-label="隐藏 HeroSMS API Key" aria-label="显示 HeroSMS API Key" title="显示 HeroSMS API Key"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-five-sim-api-key" style="display:none;">
|
||||
<span class="data-label">5sim API</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-five-sim-api-key" class="data-input data-input-with-icon mono"
|
||||
placeholder="请输入 5sim API Key(Bearer Token)" />
|
||||
<button id="btn-toggle-five-sim-api-key" class="input-icon-btn" type="button"
|
||||
data-password-toggle="input-five-sim-api-key" data-show-label="显示 5sim API Key"
|
||||
data-hide-label="隐藏 5sim API Key" aria-label="显示 5sim API Key" title="显示 5sim API Key"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-five-sim-country" style="display:none;">
|
||||
<span class="data-label">国家优先级</span>
|
||||
<div class="data-inline hero-sms-country-stack">
|
||||
<select id="select-five-sim-country" class="data-input mono" multiple size="6" style="display:none;">
|
||||
<option value="thailand" selected>泰国 (Thailand) [TH]</option>
|
||||
</select>
|
||||
<div class="hero-sms-country-mainline">
|
||||
<div id="five-sim-country-menu-shell" class="hero-sms-country-menu">
|
||||
<button id="btn-five-sim-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
未选择 (0/3)
|
||||
</button>
|
||||
<div id="five-sim-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-five-sim-country-fallback" style="display:none;">
|
||||
<span class="data-label">生效顺序</span>
|
||||
<div class="data-inline data-value-actions">
|
||||
<span id="display-five-sim-country-fallback-order" class="data-value data-value-fill mono country-order-list">未设置</span>
|
||||
<button id="btn-five-sim-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-five-sim-operator" style="display:none;">
|
||||
<span class="data-label">运营商</span>
|
||||
<input type="text" id="input-five-sim-operator" class="data-input mono"
|
||||
placeholder="any" />
|
||||
</div>
|
||||
<div class="data-row" id="row-five-sim-product" style="display:none;">
|
||||
<span class="data-label">产品代码</span>
|
||||
<input type="text" id="input-five-sim-product" class="data-input mono"
|
||||
placeholder="openai" />
|
||||
</div>
|
||||
<div class="data-row" id="row-nex-sms-api-key" style="display:none;">
|
||||
<span class="data-label">NexSMS API</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-nex-sms-api-key" class="data-input data-input-with-icon mono"
|
||||
placeholder="请输入 NexSMS API Key" />
|
||||
<button id="btn-toggle-nex-sms-api-key" class="input-icon-btn" type="button"
|
||||
data-password-toggle="input-nex-sms-api-key" data-show-label="显示 NexSMS API Key"
|
||||
data-hide-label="隐藏 NexSMS API Key" aria-label="显示 NexSMS API Key" title="显示 NexSMS API Key"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-nex-sms-country" style="display:none;">
|
||||
<span class="data-label">国家优先级</span>
|
||||
<div class="data-inline hero-sms-country-stack">
|
||||
<select id="select-nex-sms-country" class="data-input mono" multiple size="6" style="display:none;">
|
||||
<option value="1" selected>Country #1</option>
|
||||
</select>
|
||||
<div class="hero-sms-country-mainline">
|
||||
<div id="nex-sms-country-menu-shell" class="hero-sms-country-menu">
|
||||
<button id="btn-nex-sms-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
Country #1 (1/3)
|
||||
</button>
|
||||
<div id="nex-sms-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-nex-sms-country-fallback" style="display:none;">
|
||||
<span class="data-label">生效顺序</span>
|
||||
<div class="data-inline data-value-actions">
|
||||
<span id="display-nex-sms-country-fallback-order" class="data-value data-value-fill mono country-order-list">未设置</span>
|
||||
<button id="btn-nex-sms-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-nex-sms-service-code" style="display:none;">
|
||||
<span class="data-label">服务代码</span>
|
||||
<input type="text" id="input-nex-sms-service-code" class="data-input mono"
|
||||
placeholder="ot" />
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-max-price" style="display:none;">
|
||||
<span class="data-label">价格</span>
|
||||
<div class="data-inline hero-sms-price-preview-stack">
|
||||
<div class="hero-sms-price-preview-head">
|
||||
<button id="btn-hero-sms-price-preview" class="btn btn-outline btn-xs data-inline-btn" type="button">查询价格</button>
|
||||
</div>
|
||||
<div id="row-hero-sms-price-tiers" class="hero-sms-price-preview-result" style="display:none;">
|
||||
<span id="display-hero-sms-price-tiers" class="data-value mono hero-sms-price-preview-text">未获取</span>
|
||||
</div>
|
||||
<div class="hero-sms-price-controls-grid">
|
||||
<div class="hero-sms-price-control">
|
||||
<span class="hero-sms-settings-caption">价格上限</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-hero-sms-max-price" class="data-input auto-delay-input mono hero-sms-max-price-input" placeholder="0.12" min="0" step="0.0001" title="接码价格上限;可空(空=自动价格)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-sms-price-control">
|
||||
<span class="hero-sms-settings-caption">指定档位</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-hero-sms-preferred-price" class="data-input auto-delay-input mono hero-sms-max-price-input" placeholder="例如 0.0512(可空)" min="0" step="0.0001" title="优先尝试该价格档位;可空(空=按优先级策略)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-sms-price-control hero-sms-price-control-reuse">
|
||||
<span class="hero-sms-settings-caption">号码复用</span>
|
||||
<div class="setting-controls hero-sms-toggle-controls">
|
||||
<label class="toggle-switch hero-sms-price-reuse-toggle" for="input-hero-sms-reuse-enabled" title="开启后会优先复用未超次数的可用号码">
|
||||
<input type="checkbox" id="input-hero-sms-reuse-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-phone-code-settings-group" style="display:none;">
|
||||
<span class="data-label">接码参数</span>
|
||||
<div class="data-inline hero-sms-settings-grid">
|
||||
<div id="row-phone-verification-resend-count" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">验证码重发</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-verification-resend-count" class="data-input auto-delay-input" value="4"
|
||||
min="0" max="20" step="1" title="自动点击重新发送验证码的次数" />
|
||||
<span class="data-unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-replacement-limit" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">换号上限</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-replacement-limit" class="data-input auto-delay-input" value="3" min="1" max="20" step="1" title="步骤 9 内部允许更换号码的最大次数" />
|
||||
<span class="data-unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-code-wait-seconds" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">验证码限时</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-code-wait-seconds" class="data-input auto-delay-input" value="60" min="15" max="300" step="1" title="每轮等待验证码的秒数" />
|
||||
<span class="data-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-code-timeout-windows" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">超时次数</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-code-timeout-windows" class="data-input auto-delay-input" value="2" min="1" max="10" step="1" title="验证码超时后,最多继续等待几轮再换号" />
|
||||
<span class="data-unit">轮</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-code-poll-interval-seconds" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">轮询间隔</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-code-poll-interval-seconds" class="data-input auto-delay-input" value="5" min="1" max="30" step="1" title="向 HeroSMS 查询验证码状态的间隔秒数" />
|
||||
<span class="data-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-phone-code-poll-max-rounds" class="hero-sms-settings-cell" style="display:none;">
|
||||
<span class="hero-sms-settings-caption">轮询次数</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-phone-code-poll-max-rounds" class="data-input auto-delay-input" value="4" min="1" max="120" step="1" title="每轮验证码等待窗口最多轮询次数" />
|
||||
<span class="data-unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-hero-sms-runtime-pair" style="display:none;">
|
||||
<span class="data-label">运行状态</span>
|
||||
<div class="data-inline hero-sms-runtime-grid">
|
||||
<div id="row-hero-sms-current-number" class="hero-sms-runtime-cell" style="display:none;">
|
||||
<span class="hero-sms-runtime-key">当前分配</span>
|
||||
<span id="display-hero-sms-current-number" class="data-value mono hero-sms-runtime-value">未分配</span>
|
||||
</div>
|
||||
<div id="row-hero-sms-current-countdown" class="hero-sms-runtime-cell" style="display:none;">
|
||||
<span class="hero-sms-runtime-key">倒计时</span>
|
||||
<span id="display-hero-sms-current-countdown" class="data-value mono hero-sms-runtime-value">未启动</span>
|
||||
</div>
|
||||
<div id="row-hero-sms-current-code" class="hero-sms-runtime-cell" style="display:none;">
|
||||
<span class="hero-sms-runtime-key">验证码</span>
|
||||
<span id="display-hero-sms-current-code" class="data-value mono hero-sms-runtime-value">未获取</span>
|
||||
</div>
|
||||
<div id="row-hero-sms-preferred-activation" class="hero-sms-runtime-cell hero-sms-runtime-cell-span2" style="display:none;">
|
||||
<span class="hero-sms-runtime-key">优先号码</span>
|
||||
<select id="select-hero-sms-preferred-activation" class="data-input mono hero-sms-runtime-select">
|
||||
<option value="">自动(先复用已有可用号,再创建新号)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-bar" class="status-bar">
|
||||
<div class="status-dot"></div>
|
||||
<span id="display-status">就绪</span>
|
||||
|
||||
+3432
-178
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ function createRecoveryApi(state) {
|
||||
isActionEnabled: (element) => Boolean(element) && !element.disabled && element.getAttribute('aria-disabled') !== 'true',
|
||||
isVisibleElement: () => true,
|
||||
log: () => {},
|
||||
routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405/i,
|
||||
routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i,
|
||||
simulateClick: () => {
|
||||
state.clickCount += 1;
|
||||
if (typeof state.onClick === 'function') {
|
||||
@@ -103,6 +103,42 @@ test('auth page recovery detects route error retry page on email verification ro
|
||||
assert.equal(snapshot.routeErrorMatched, true);
|
||||
});
|
||||
|
||||
test('auth page recovery detects email route missing-action retry page text', () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
pageText: "Error: You made a POST request to \"/email-verification\" but did not provide an `action` for route \"EMAIL_VERIFICATION\"",
|
||||
pathname: '/email-verification',
|
||||
retryVisible: true,
|
||||
title: '',
|
||||
};
|
||||
const api = createRecoveryApi(state);
|
||||
|
||||
const snapshot = api.getAuthTimeoutErrorPageState({
|
||||
pathPatterns: [/\/email-verification(?:[/?#]|$)/i],
|
||||
});
|
||||
|
||||
assert.equal(Boolean(snapshot), true);
|
||||
assert.equal(snapshot.routeErrorMatched, true);
|
||||
});
|
||||
|
||||
test('auth page recovery detects failed-to-fetch retry page on email verification route', () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
pageText: 'Oops, an error occurred! Failed to fetch',
|
||||
pathname: '/email-verification',
|
||||
retryVisible: true,
|
||||
title: 'Oops, an error occurred!',
|
||||
};
|
||||
const api = createRecoveryApi(state);
|
||||
|
||||
const snapshot = api.getAuthTimeoutErrorPageState({
|
||||
pathPatterns: [/\/email-verification(?:[/?#]|$)/i],
|
||||
});
|
||||
|
||||
assert.equal(Boolean(snapshot), true);
|
||||
assert.equal(snapshot.fetchFailedMatched, true);
|
||||
});
|
||||
|
||||
test('auth page recovery clicks retry and waits until page recovers', async () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
@@ -225,4 +261,3 @@ test('auth page recovery throws signup user already exists error without clickin
|
||||
|
||||
assert.equal(state.clickCount, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -169,6 +169,327 @@ test('auto-run controller skips add-phone failures to the next round instead of
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
|
||||
test('auto-run controller treats phone-number supply exhaustion as round-fatal and skips same-round retries', async () => {
|
||||
const events = {
|
||||
logs: [],
|
||||
broadcasts: [],
|
||||
accountRecords: [],
|
||||
runCalls: 0,
|
||||
};
|
||||
|
||||
let currentState = {
|
||||
stepStatuses: {},
|
||||
vpsUrl: 'https://example.com/vps',
|
||||
vpsPassword: 'secret',
|
||||
customPassword: '',
|
||||
autoRunSkipFailures: true,
|
||||
autoRunFallbackThreadIntervalMinutes: 0,
|
||||
autoRunDelayEnabled: false,
|
||||
autoRunDelayMinutes: 30,
|
||||
autoStepDelaySeconds: null,
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'duck',
|
||||
gmailBaseEmail: '',
|
||||
mail2925BaseEmail: '',
|
||||
emailPrefix: 'demo',
|
||||
inbucketHost: '',
|
||||
inbucketMailbox: '',
|
||||
cloudflareDomain: '',
|
||||
cloudflareDomains: [],
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
autoRunRoundSummaries: [],
|
||||
};
|
||||
|
||||
const runtime = {
|
||||
state: {
|
||||
autoRunActive: false,
|
||||
autoRunCurrentRun: 0,
|
||||
autoRunTotalRuns: 1,
|
||||
autoRunAttemptRun: 0,
|
||||
autoRunSessionId: 0,
|
||||
},
|
||||
get() {
|
||||
return { ...this.state };
|
||||
},
|
||||
set(updates = {}) {
|
||||
this.state = { ...this.state, ...updates };
|
||||
},
|
||||
};
|
||||
|
||||
let sessionSeed = 0;
|
||||
|
||||
const controller = api.createAutoRunController({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
appendAccountRunRecord: async (status, _state, reason) => {
|
||||
events.accountRecords.push({ status, reason });
|
||||
return { status, reason };
|
||||
},
|
||||
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
|
||||
AUTO_RUN_RETRY_DELAY_MS: 3000,
|
||||
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
|
||||
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
|
||||
broadcastAutoRunStatus: async (phase, payload = {}) => {
|
||||
events.broadcasts.push({ phase, ...payload });
|
||||
currentState = {
|
||||
...currentState,
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
|
||||
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
|
||||
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
|
||||
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
|
||||
};
|
||||
},
|
||||
broadcastStopToContentScripts: async () => {},
|
||||
cancelPendingCommands: () => {},
|
||||
clearStopRequest: () => {},
|
||||
createAutoRunSessionId: () => {
|
||||
sessionSeed += 1;
|
||||
return sessionSeed;
|
||||
},
|
||||
getAutoRunStatusPayload: (phase, payload = {}) => ({
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? 0,
|
||||
autoRunTotalRuns: payload.totalRuns ?? 1,
|
||||
autoRunAttemptRun: payload.attemptRun ?? 0,
|
||||
autoRunSessionId: payload.sessionId ?? 0,
|
||||
}),
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getFirstUnfinishedStep: () => 1,
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getRunningSteps: () => [],
|
||||
getState: async () => ({
|
||||
...currentState,
|
||||
stepStatuses: { ...(currentState.stepStatuses || {}) },
|
||||
tabRegistry: { ...(currentState.tabRegistry || {}) },
|
||||
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
|
||||
}),
|
||||
getStopRequested: () => false,
|
||||
hasSavedProgress: () => false,
|
||||
isAddPhoneAuthFailure: () => false,
|
||||
isRestartCurrentAttemptError: () => false,
|
||||
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
|
||||
launchAutoRunTimerPlan: async () => false,
|
||||
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
|
||||
persistAutoRunTimerPlan: async () => ({}),
|
||||
resetState: async () => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
stepStatuses: {},
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
};
|
||||
},
|
||||
runAutoSequenceFromStep: async () => {
|
||||
events.runCalls += 1;
|
||||
if (events.runCalls === 1) {
|
||||
throw new Error('HeroSMS no numbers available across 1 country candidate(s): Thailand: NO_NUMBERS.');
|
||||
}
|
||||
},
|
||||
runtime,
|
||||
setState: async (updates = {}) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
|
||||
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
|
||||
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
|
||||
};
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfAutoRunSessionStopped: (sessionId) => {
|
||||
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
|
||||
throw new Error('流程已被用户停止。');
|
||||
}
|
||||
},
|
||||
waitForRunningStepsToFinish: async () => currentState,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.autoRunLoop(2, {
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
});
|
||||
|
||||
assert.equal(events.runCalls, 2, 'number supply failure should fail current round and continue next round');
|
||||
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'number supply failure should not enter same-round retrying phase');
|
||||
assert.equal(events.accountRecords.length, 1, 'number supply failure should persist one failed round record');
|
||||
assert.equal(events.accountRecords[0].status, 'failed');
|
||||
assert.match(events.accountRecords[0].reason, /NO_NUMBERS/i);
|
||||
assert.ok(events.logs.some(({ message }) => /接码号池/.test(message)));
|
||||
assert.equal(events.broadcasts.some(({ phase }) => phase === 'stopped'), false);
|
||||
assert.equal(runtime.state.autoRunActive, false);
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
|
||||
test('auto-run controller keeps same-round retrying for step9 local replacement exhaustion errors', async () => {
|
||||
const events = {
|
||||
logs: [],
|
||||
broadcasts: [],
|
||||
accountRecords: [],
|
||||
runCalls: 0,
|
||||
};
|
||||
|
||||
let currentState = {
|
||||
stepStatuses: {},
|
||||
vpsUrl: 'https://example.com/vps',
|
||||
vpsPassword: 'secret',
|
||||
customPassword: '',
|
||||
autoRunSkipFailures: true,
|
||||
autoRunFallbackThreadIntervalMinutes: 0,
|
||||
autoRunDelayEnabled: false,
|
||||
autoRunDelayMinutes: 30,
|
||||
autoStepDelaySeconds: null,
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'duck',
|
||||
gmailBaseEmail: '',
|
||||
mail2925BaseEmail: '',
|
||||
emailPrefix: 'demo',
|
||||
inbucketHost: '',
|
||||
inbucketMailbox: '',
|
||||
cloudflareDomain: '',
|
||||
cloudflareDomains: [],
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
autoRunRoundSummaries: [],
|
||||
};
|
||||
|
||||
const runtime = {
|
||||
state: {
|
||||
autoRunActive: false,
|
||||
autoRunCurrentRun: 0,
|
||||
autoRunTotalRuns: 1,
|
||||
autoRunAttemptRun: 0,
|
||||
autoRunSessionId: 0,
|
||||
},
|
||||
get() {
|
||||
return { ...this.state };
|
||||
},
|
||||
set(updates = {}) {
|
||||
this.state = { ...this.state, ...updates };
|
||||
},
|
||||
};
|
||||
|
||||
let sessionSeed = 0;
|
||||
|
||||
const controller = api.createAutoRunController({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
appendAccountRunRecord: async (status, _state, reason) => {
|
||||
events.accountRecords.push({ status, reason });
|
||||
return { status, reason };
|
||||
},
|
||||
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
|
||||
AUTO_RUN_RETRY_DELAY_MS: 3000,
|
||||
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
|
||||
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
|
||||
broadcastAutoRunStatus: async (phase, payload = {}) => {
|
||||
events.broadcasts.push({ phase, ...payload });
|
||||
currentState = {
|
||||
...currentState,
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
|
||||
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
|
||||
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
|
||||
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
|
||||
};
|
||||
},
|
||||
broadcastStopToContentScripts: async () => {},
|
||||
cancelPendingCommands: () => {},
|
||||
clearStopRequest: () => {},
|
||||
createAutoRunSessionId: () => {
|
||||
sessionSeed += 1;
|
||||
return sessionSeed;
|
||||
},
|
||||
getAutoRunStatusPayload: (phase, payload = {}) => ({
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? 0,
|
||||
autoRunTotalRuns: payload.totalRuns ?? 1,
|
||||
autoRunAttemptRun: payload.attemptRun ?? 0,
|
||||
autoRunSessionId: payload.sessionId ?? 0,
|
||||
}),
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getFirstUnfinishedStep: () => 1,
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getRunningSteps: () => [],
|
||||
getState: async () => ({
|
||||
...currentState,
|
||||
stepStatuses: { ...(currentState.stepStatuses || {}) },
|
||||
tabRegistry: { ...(currentState.tabRegistry || {}) },
|
||||
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
|
||||
}),
|
||||
getStopRequested: () => false,
|
||||
hasSavedProgress: () => false,
|
||||
isAddPhoneAuthFailure: () => false,
|
||||
isRestartCurrentAttemptError: () => false,
|
||||
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
|
||||
launchAutoRunTimerPlan: async () => false,
|
||||
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
|
||||
persistAutoRunTimerPlan: async () => ({}),
|
||||
resetState: async () => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
stepStatuses: {},
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
};
|
||||
},
|
||||
runAutoSequenceFromStep: async () => {
|
||||
events.runCalls += 1;
|
||||
if (events.runCalls === 1) {
|
||||
throw new Error('Step 9: phone verification did not succeed after 3 number replacements. Last reason: sms_timeout_after_2_windows.');
|
||||
}
|
||||
},
|
||||
runtime,
|
||||
setState: async (updates = {}) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
|
||||
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
|
||||
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
|
||||
};
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfAutoRunSessionStopped: (sessionId) => {
|
||||
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
|
||||
throw new Error('流程已被用户停止。');
|
||||
}
|
||||
},
|
||||
waitForRunningStepsToFinish: async () => currentState,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.autoRunLoop(1, {
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
});
|
||||
|
||||
assert.equal(events.runCalls, 2, 'step9 local replacement exhaustion should retry in same round before success');
|
||||
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), true, 'should enter retrying phase for same-round retry');
|
||||
assert.equal(events.logs.some(({ message }) => /接码号池/.test(message)), false, 'should not be misclassified as global phone supply exhaustion');
|
||||
assert.equal(events.accountRecords.length, 0, 'eventual same-round success should not persist failed round record');
|
||||
});
|
||||
|
||||
test('auto-run controller skips user_already_exists failures to the next round instead of retrying the same round', async () => {
|
||||
const events = {
|
||||
logs: [],
|
||||
|
||||
@@ -67,6 +67,7 @@ function createHarness(options = {}) {
|
||||
failureBudget = 1,
|
||||
failureMessage = '认证失败: Request failed with status code 502',
|
||||
authState = { state: 'password_page', url: 'https://auth.openai.com/log-in' },
|
||||
customState = {},
|
||||
} = options;
|
||||
|
||||
return new Function(`
|
||||
@@ -97,8 +98,30 @@ async function getState() {
|
||||
return {
|
||||
stepStatuses: { 3: 'completed' },
|
||||
mailProvider: '163',
|
||||
...${JSON.stringify(customState)},
|
||||
};
|
||||
}
|
||||
function getStepIdsForState() {
|
||||
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
}
|
||||
function getStepDefinitionForState(step) {
|
||||
const map = {
|
||||
1: { key: 'open-signup' },
|
||||
2: { key: 'prepare-email' },
|
||||
3: { key: 'fill-password' },
|
||||
4: { key: 'verify-email' },
|
||||
5: { key: 'profile-basic' },
|
||||
6: { key: 'profile-finish' },
|
||||
7: { key: 'auth-login' },
|
||||
8: { key: 'auth-email-code' },
|
||||
9: { key: 'confirm-oauth' },
|
||||
10: { key: 'platform-verify' },
|
||||
};
|
||||
return map[Number(step)] || null;
|
||||
}
|
||||
function getStepExecutionKeyForState(step, state = {}) {
|
||||
return String(getStepDefinitionForState(step, state)?.key || '').trim();
|
||||
}
|
||||
function isStopError(error) {
|
||||
return (error?.message || String(error || '')) === '流程已被用户停止。';
|
||||
}
|
||||
@@ -246,3 +269,31 @@ test('auto-run stop errors after step 7 are rethrown immediately instead of rest
|
||||
assert.deepStrictEqual(result.events.steps, [7, 8, 9]);
|
||||
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
|
||||
});
|
||||
|
||||
test('auto-run restarts from confirm-oauth step after transient step10 token_exchange_user_error', async () => {
|
||||
const harness = createHarness({
|
||||
failureStep: 10,
|
||||
failureBudget: 1,
|
||||
failureMessage: 'token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }',
|
||||
authState: { state: 'oauth_consent_page', url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent' },
|
||||
customState: {
|
||||
panelMode: 'sub2api',
|
||||
stepStatuses: { 3: 'completed' },
|
||||
stepsVersion: 'ultra2.0',
|
||||
visibleStep: 10,
|
||||
contributionMode: false,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await harness.run();
|
||||
|
||||
assert.deepStrictEqual(events.steps, [7, 8, 9, 10, 9, 10]);
|
||||
assert.equal(events.invalidations.length, 1);
|
||||
assert.deepStrictEqual(events.invalidations[0], {
|
||||
step: 8,
|
||||
options: {
|
||||
logLabel: '步骤 10 报错后准备回到步骤 9 重试(第 1 次重开)',
|
||||
},
|
||||
});
|
||||
assert.ok(events.logs.some(({ message }) => /回到步骤 9 重新开始授权流程/.test(message)));
|
||||
});
|
||||
|
||||
@@ -54,6 +54,12 @@ test('background account history settings are normalized independently from hotm
|
||||
extractFunction('normalizeHotmailLocalBaseUrl'),
|
||||
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
|
||||
extractFunction('normalizeVerificationResendCount'),
|
||||
extractFunction('normalizePhoneSmsProvider'),
|
||||
extractFunction('normalizePhoneSmsProviderOrder'),
|
||||
extractFunction('normalizeFiveSimCountryCode'),
|
||||
extractFunction('normalizeFiveSimCountryOrder'),
|
||||
extractFunction('normalizeNexSmsCountryId'),
|
||||
extractFunction('normalizeNexSmsCountryOrder'),
|
||||
extractFunction('normalizePhoneVerificationReplacementLimit'),
|
||||
extractFunction('normalizePhoneCodeWaitSeconds'),
|
||||
extractFunction('normalizePhoneCodeTimeoutWindows'),
|
||||
@@ -136,6 +142,7 @@ return {
|
||||
assert.equal(api.normalizePersistentSettingValue('phoneCodePollMaxRounds', '18'), 18);
|
||||
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0.123456'), '0.1235');
|
||||
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0'), '');
|
||||
assert.equal(api.normalizePersistentSettingValue('heroSmsCountryId', 0), 0);
|
||||
assert.deepStrictEqual(
|
||||
api.normalizePersistentSettingValue('heroSmsCountryFallback', [{ id: 16, label: 'United Kingdom' }, { id: 52 }]),
|
||||
[{ id: 16, label: 'United Kingdom' }, { id: 52, label: 'Country #52' }]
|
||||
@@ -172,4 +179,16 @@ return {
|
||||
api.normalizePersistentSettingValue('codex2apiAdminKey', ' secret-key '),
|
||||
'secret-key'
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
api.normalizePersistentSettingValue('phoneSmsProviderOrder', []),
|
||||
[]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
api.normalizePersistentSettingValue('fiveSimCountryOrder', []),
|
||||
[]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
api.normalizePersistentSettingValue('nexSmsCountryOrder', []),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -392,3 +392,97 @@ return {
|
||||
}]);
|
||||
assert.match(events.logs[0].message, /授权后链总超时已关闭/);
|
||||
});
|
||||
|
||||
test('executeStep retries fetch-network errors for step 4 with cooldown and bounded attempts', async () => {
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
|
||||
const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
|
||||
const STEP_FETCH_NETWORK_RETRY_POLICIES = new Map([[4, { maxAttempts: 3, cooldownMs: 1 }]]);
|
||||
let activeTopLevelAuthChainExecution = null;
|
||||
let stopRequested = false;
|
||||
const events = {
|
||||
logs: [],
|
||||
statusCalls: [],
|
||||
registryCalls: [],
|
||||
sleepCalls: [],
|
||||
};
|
||||
let runCount = 0;
|
||||
|
||||
async function addLog(message, level = 'info') {
|
||||
events.logs.push({ message, level });
|
||||
}
|
||||
async function setStepStatus(step, status) {
|
||||
events.statusCalls.push({ step, status });
|
||||
}
|
||||
async function humanStepDelay() {}
|
||||
async function getState() {
|
||||
return {
|
||||
flowStartTime: null,
|
||||
stepStatuses: {},
|
||||
};
|
||||
}
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
async function appendManualAccountRunRecordIfNeeded() {}
|
||||
function isTerminalSecurityBlockedError() {
|
||||
return false;
|
||||
}
|
||||
async function handleCloudflareSecurityBlocked() {}
|
||||
function doesStepUseCompletionSignal() {
|
||||
return false;
|
||||
}
|
||||
async function sleepWithStop(ms) {
|
||||
events.sleepCalls.push(ms);
|
||||
}
|
||||
const stepRegistry = {
|
||||
getStepDefinition(step) {
|
||||
return { id: step, key: 'fetch-signup-code' };
|
||||
},
|
||||
async executeStep(step) {
|
||||
events.registryCalls.push(step);
|
||||
runCount += 1;
|
||||
if (runCount < 3) {
|
||||
throw new TypeError('Failed to fetch');
|
||||
}
|
||||
},
|
||||
};
|
||||
function getStepRegistryForState() {
|
||||
return stepRegistry;
|
||||
}
|
||||
function getStepDefinitionForState(step) {
|
||||
return { id: step, key: 'fetch-signup-code' };
|
||||
}
|
||||
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('isRetryableContentScriptTransportError')}
|
||||
${extractFunction('isStepFetchNetworkRetryableError')}
|
||||
${extractFunction('getStepFetchNetworkRetryPolicy')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
${extractFunction('isAuthChainStep')}
|
||||
${extractFunction('acquireTopLevelAuthChainExecution')}
|
||||
${extractFunction('isBrowserSwitchRequiredError')}
|
||||
${extractFunction('getBrowserSwitchRequiredMessage')}
|
||||
${extractFunction('handleBrowserSwitchRequired')}
|
||||
${extractFunction('executeStep')}
|
||||
|
||||
return {
|
||||
executeStep,
|
||||
snapshot() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await api.executeStep(4);
|
||||
|
||||
const events = api.snapshot();
|
||||
assert.deepStrictEqual(events.registryCalls, [4, 4, 4]);
|
||||
assert.deepStrictEqual(events.sleepCalls, [1, 1]);
|
||||
assert.equal(
|
||||
events.logs.filter(({ message }) => message.includes('[NETWORK_FETCH_RETRY]')).length >= 3,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,8 +19,8 @@ test('icloud login helper distinguishes auth-required errors from transient cont
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/if \(isIcloudTransientContextError\(err\)\) \{[\s\S]*iCloud 别名加载受网络\/上下文波动影响,请稍后重试。/m,
|
||||
'withIcloudLoginHelp should surface transient-context copy instead of forcing login prompt'
|
||||
/if \(isIcloudTransientContextError\(err\)\) \{[\s\S]*safeActionLabel[\s\S]*iCloud:\$\{safeActionLabel\}受网络\/上下文波动影响,请稍后重试。/m,
|
||||
'withIcloudLoginHelp should surface action-scoped transient-context copy instead of forcing login prompt'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
|
||||
@@ -30,6 +30,7 @@ ${providerSource}
|
||||
const transformIpProxyAccountEntryByProvider = self.transformIpProxyAccountEntryByProvider;
|
||||
${coreSource}
|
||||
return {
|
||||
applyExitRegionExpectation,
|
||||
buildIpProxyPacScript,
|
||||
getAccountModeProxyPoolFromState,
|
||||
normalizeIpProxyAccountList,
|
||||
@@ -133,3 +134,30 @@ test('IP proxy auto-switch threshold is clamped to the supported range', () => {
|
||||
assert.equal(api.resolveIpProxyAutoSwitchThreshold({ ipProxyPoolTargetCount: '25' }), 25);
|
||||
assert.equal(api.resolveIpProxyAutoSwitchThreshold({ ipProxyPoolTargetCount: '9999' }), 500);
|
||||
});
|
||||
|
||||
test('711 proxy region mismatch with missing auth challenge keeps routing as warning instead of hard failure', () => {
|
||||
const api = loadIpProxyCore();
|
||||
|
||||
const status = api.applyExitRegionExpectation({
|
||||
applied: true,
|
||||
reason: 'applied',
|
||||
provider: '711proxy',
|
||||
hasAuth: true,
|
||||
username: 'USER047152-zone-custom-region-US',
|
||||
entrySource: 'fixed_account',
|
||||
exitIp: '1.2.3.4',
|
||||
exitRegion: 'BR',
|
||||
authDiagnostics: 'auth(challenge=0,provided=0,isProxy=n/a,status=0,host=unknown)',
|
||||
error: '',
|
||||
warning: '',
|
||||
}, 'US');
|
||||
|
||||
assert.equal(status.applied, true);
|
||||
assert.equal(status.reason, 'applied_with_warning');
|
||||
assert.equal(status.error, '');
|
||||
assert.match(
|
||||
String(status.warning || ''),
|
||||
/地区校验未通过且未触发代理鉴权挑战,疑似匿名链路;先保留代理接管并给出强告警/
|
||||
);
|
||||
assert.match(String(status.warning || ''), /期望 US,实际 BR/);
|
||||
});
|
||||
|
||||
@@ -79,3 +79,120 @@ test('platform verify module supports codex2api protocol callback exchange', asy
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('platform verify retries transient SUB2API oauth/token exchange errors before failing', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
|
||||
const logs = [];
|
||||
const attempts = [];
|
||||
let callCount = 0;
|
||||
const executor = api.createStep10Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
closeConflictingTabsForSource: async () => {},
|
||||
completeStepFromBackground: async () => {},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
getPanelMode: () => 'sub2api',
|
||||
getTabId: async () => 12,
|
||||
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
|
||||
isTabAlive: async () => true,
|
||||
normalizeCodex2ApiUrl: (value) => value,
|
||||
normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts',
|
||||
rememberSourceLastUrl: async () => {},
|
||||
reuseOrCreateTab: async () => 12,
|
||||
sendToContentScript: async (_source, message) => {
|
||||
attempts.push(message.type);
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
error: 'request failed: Post "https://auth.openai.com/oauth/token": unexpected EOF',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
shouldBypassStep9ForLocalCpa: () => false,
|
||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
||||
});
|
||||
|
||||
await executor.executeStep10({
|
||||
panelMode: 'sub2api',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
sub2apiUrl: 'https://sub2api.example.com/admin/accounts',
|
||||
sub2apiEmail: 'flow@example.com',
|
||||
sub2apiPassword: 'secret',
|
||||
sub2apiSessionId: 'session-123',
|
||||
sub2apiOAuthState: 'oauth-state',
|
||||
});
|
||||
|
||||
assert.equal(callCount, 2);
|
||||
assert.deepStrictEqual(attempts, ['EXECUTE_STEP', 'EXECUTE_STEP']);
|
||||
assert.equal(
|
||||
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('platform verify retries transient SUB2API token_exchange_user_error before succeeding', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
|
||||
const logs = [];
|
||||
let callCount = 0;
|
||||
const executor = api.createStep10Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
closeConflictingTabsForSource: async () => {},
|
||||
completeStepFromBackground: async () => {},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
getPanelMode: () => 'sub2api',
|
||||
getTabId: async () => 12,
|
||||
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
|
||||
isTabAlive: async () => true,
|
||||
normalizeCodex2ApiUrl: (value) => value,
|
||||
normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts',
|
||||
rememberSourceLastUrl: async () => {},
|
||||
reuseOrCreateTab: async () => 12,
|
||||
sendToContentScript: async () => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
error: 'token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
shouldBypassStep9ForLocalCpa: () => false,
|
||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
||||
});
|
||||
|
||||
await executor.executeStep10({
|
||||
panelMode: 'sub2api',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
sub2apiUrl: 'https://sub2api.example.com/admin/accounts',
|
||||
sub2apiEmail: 'flow@example.com',
|
||||
sub2apiPassword: 'secret',
|
||||
sub2apiSessionId: 'session-123',
|
||||
sub2apiOAuthState: 'oauth-state',
|
||||
});
|
||||
|
||||
assert.equal(callCount, 2);
|
||||
assert.equal(
|
||||
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
@@ -45,7 +45,9 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling',
|
||||
reuseOrCreateTab: async (source, url) => {
|
||||
tabReuses.push({ source, url });
|
||||
},
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
isRetryableContentScriptTransportError: () => false,
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
@@ -100,7 +102,9 @@ test('step 4 does not request a fresh code first for Cloudflare temp mail', asyn
|
||||
capturedOptions = options;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
isRetryableContentScriptTransportError: () => false,
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
@@ -151,7 +155,9 @@ test('step 4 checks iCloud session before polling iCloud mailbox', async () => {
|
||||
resolved = true;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
isRetryableContentScriptTransportError: () => false,
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
@@ -197,10 +203,15 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
|
||||
resolveCalls += 1;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => ({
|
||||
alreadyVerified: true,
|
||||
skipProfileStep: true,
|
||||
}),
|
||||
sendToContentScriptResilient: async () => ({
|
||||
alreadyVerified: true,
|
||||
skipProfileStep: true,
|
||||
}),
|
||||
isRetryableContentScriptTransportError: () => false,
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
@@ -219,3 +230,73 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
|
||||
]);
|
||||
assert.equal(resolveCalls, 0);
|
||||
});
|
||||
|
||||
test('step 4 prepare retries transport by recovering retry page without replaying full prepare loop', async () => {
|
||||
let sendToContentScriptCalls = 0;
|
||||
let recoverCalls = 0;
|
||||
let resolveCalls = 0;
|
||||
const logs = [];
|
||||
|
||||
const executor = api.createStep4Executor({
|
||||
addLog: async (message, level) => {
|
||||
logs.push({ message, level: level || 'info' });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureMail2925MailboxSession: async () => {},
|
||||
getMailConfig: () => ({
|
||||
provider: '163',
|
||||
label: '163 邮箱',
|
||||
source: 'mail-163',
|
||||
url: 'https://mail.163.com',
|
||||
}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
resolveVerificationStep: async () => {
|
||||
resolveCalls += 1;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type !== 'PREPARE_SIGNUP_VERIFICATION') {
|
||||
return {};
|
||||
}
|
||||
sendToContentScriptCalls += 1;
|
||||
if (sendToContentScriptCalls === 1) {
|
||||
throw new Error('Content script on signup-page did not respond in 30s. Try refreshing the tab and retry.');
|
||||
}
|
||||
return { ready: true };
|
||||
},
|
||||
sendToContentScriptResilient: async (_source, message) => {
|
||||
if (message.type === 'RECOVER_AUTH_RETRY_PAGE') {
|
||||
recoverCalls += 1;
|
||||
return { recovered: true };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
isRetryableContentScriptTransportError: (error) => /did not respond in \d+s/i.test(String(error?.message || error)),
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await executor.executeStep4({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
assert.equal(sendToContentScriptCalls, 2);
|
||||
assert.equal(recoverCalls, 1);
|
||||
assert.equal(resolveCalls, 1);
|
||||
assert.equal(
|
||||
logs.some((entry) => /正在确认注册验证码页面是否就绪/.test(entry.message)),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
@@ -193,3 +193,104 @@ return {
|
||||
assert.equal(api.isThreadItemSelected(staleItem, api.buildItemSignature(selectedItem)), true);
|
||||
assert.equal(api.isThreadItemSelected(staleItem, api.buildItemSignature(staleItem)), false);
|
||||
});
|
||||
|
||||
test('icloud poll session baseline is reused across calls and enables fallback after carry-over attempts', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('getThreadItemMetadata'),
|
||||
extractFunction('buildItemSignature'),
|
||||
extractFunction('extractVerificationCode'),
|
||||
extractFunction('normalizePollSessionKey'),
|
||||
extractFunction('getOrCreatePollSessionBaseline'),
|
||||
extractFunction('persistPollSessionBaseline'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const ICLOUD_POLL_SESSION_CACHE = new Map();
|
||||
function log() {}
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
async function waitForElement() { return true; }
|
||||
async function refreshInbox() { return true; }
|
||||
function normalizeThreadEntry(entry) {
|
||||
return {
|
||||
signature: String(entry.signature || ''),
|
||||
sender: String(entry.sender || ''),
|
||||
subject: String(entry.subject || ''),
|
||||
preview: String(entry.preview || ''),
|
||||
timestamp: String(entry.timestamp || ''),
|
||||
ariaLabel: String(entry.ariaLabel || ''),
|
||||
};
|
||||
}
|
||||
let currentThreadData = [];
|
||||
function setThreadData(next) {
|
||||
currentThreadData = Array.isArray(next) ? next.map(normalizeThreadEntry) : [];
|
||||
}
|
||||
function collectThreadItems() {
|
||||
return currentThreadData.map((entry) => ({
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return entry.ariaLabel || entry.signature;
|
||||
return '';
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '.thread-participants') return { textContent: entry.sender };
|
||||
if (selector === '.thread-subject') return { textContent: entry.subject };
|
||||
if (selector === '.thread-preview') return { textContent: entry.preview };
|
||||
if (selector === '.thread-timestamp') return { textContent: entry.timestamp };
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
}
|
||||
async function openMailItemAndRead(item) {
|
||||
const meta = getThreadItemMetadata(item);
|
||||
return {
|
||||
sender: meta.sender,
|
||||
recipients: '',
|
||||
timestamp: meta.timestamp,
|
||||
bodyText: meta.preview,
|
||||
combinedText: meta.combinedText,
|
||||
};
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
handlePollEmail,
|
||||
setThreadData,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.setThreadData([
|
||||
{ signature: 'old-1', sender: 'OpenAI', subject: '旧邮件', preview: 'no code', timestamp: '10:00', ariaLabel: 'old-1' },
|
||||
]);
|
||||
|
||||
await assert.rejects(
|
||||
() => api.handlePollEmail(8, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['code'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 10,
|
||||
excludeCodes: [],
|
||||
sessionKey: 's-1',
|
||||
}),
|
||||
/仍未在 iCloud 邮箱中找到新的匹配邮件/
|
||||
);
|
||||
|
||||
api.setThreadData([
|
||||
{ signature: 'old-1', sender: 'OpenAI', subject: '旧邮件', preview: 'no code', timestamp: '10:00', ariaLabel: 'old-1' },
|
||||
{ signature: 'old-2', sender: 'OpenAI', subject: '旧邮件2', preview: 'no code', timestamp: '10:01', ariaLabel: 'old-2' },
|
||||
{ signature: 'old-3', sender: 'OpenAI', subject: '旧邮件3', preview: 'no code', timestamp: '10:02', ariaLabel: 'old-3' },
|
||||
{ signature: 'old-4', sender: 'OpenAI', subject: '旧邮件4', preview: 'no code', timestamp: '10:03', ariaLabel: 'old-4' },
|
||||
{ signature: 'old-code', sender: 'OpenAI', subject: 'ChatGPT Log-in Code', preview: 'enter this code 556677', timestamp: '10:04', ariaLabel: 'old-code' },
|
||||
]);
|
||||
|
||||
const result = await api.handlePollEmail(8, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['code', 'login'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 10,
|
||||
excludeCodes: [],
|
||||
sessionKey: 's-1',
|
||||
});
|
||||
|
||||
assert.equal(result.code, '556677');
|
||||
});
|
||||
|
||||
@@ -6,19 +6,27 @@ const source = fs.readFileSync('content/phone-auth.js', 'utf8');
|
||||
const globalScope = { navigator: { language: 'zh-CN' } };
|
||||
const api = new Function('self', `${source}; return self.MultiPagePhoneAuth;`)(globalScope);
|
||||
|
||||
function createFakeAddPhoneDom() {
|
||||
function createFakeAddPhoneDom(config = {}) {
|
||||
const selectEvents = [];
|
||||
const hiddenInputEvents = [];
|
||||
let submitClicked = false;
|
||||
|
||||
const options = [
|
||||
{ value: 'US', textContent: '美国' },
|
||||
{ value: 'TH', textContent: '泰国' },
|
||||
];
|
||||
const options = (
|
||||
Array.isArray(config.options) && config.options.length
|
||||
? config.options
|
||||
: [
|
||||
{ value: 'US', textContent: '美国', buttonText: '美国 (+1)' },
|
||||
{ value: 'TH', textContent: '泰国', buttonText: '泰国 (+66)' },
|
||||
]
|
||||
).map((entry) => ({
|
||||
value: String(entry?.value || '').trim(),
|
||||
textContent: String(entry?.textContent || '').trim(),
|
||||
buttonText: String(entry?.buttonText || entry?.textContent || '').trim(),
|
||||
}));
|
||||
|
||||
const select = {
|
||||
options,
|
||||
selectedIndex: 0,
|
||||
selectedIndex: Math.max(0, Math.min(options.length - 1, Number(config.selectedIndex) || 0)),
|
||||
dispatchEvent(event) {
|
||||
selectEvents.push(event?.type || '');
|
||||
return true;
|
||||
@@ -58,7 +66,7 @@ function createFakeAddPhoneDom() {
|
||||
|
||||
const selectValueNode = {
|
||||
get textContent() {
|
||||
return select.value === 'TH' ? '泰国 (+66)' : '美国 (+1)';
|
||||
return options[select.selectedIndex]?.buttonText || '';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -199,3 +207,244 @@ test('phone auth matches english HeroSMS country labels against localized add-ph
|
||||
Intl.DisplayNames = OriginalDisplayNames;
|
||||
}
|
||||
});
|
||||
|
||||
test('phone auth keeps explicit international number and auto-selects country by dial code when label lookup fails', async () => {
|
||||
const originalDocument = global.document;
|
||||
const originalEvent = global.Event;
|
||||
const originalLocation = global.location;
|
||||
const OriginalDisplayNames = Intl.DisplayNames;
|
||||
|
||||
const dom = createFakeAddPhoneDom({
|
||||
options: [
|
||||
{ value: 'CO', textContent: 'Colombia (+57)', buttonText: 'Colombia (+57)' },
|
||||
{ value: 'GB', textContent: 'United Kingdom (+44)', buttonText: 'United Kingdom (+44)' },
|
||||
],
|
||||
selectedIndex: 0,
|
||||
});
|
||||
let phoneVerificationReady = false;
|
||||
|
||||
global.document = dom.document;
|
||||
global.Event = class Event {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
};
|
||||
global.location = { href: 'https://auth.openai.com/add-phone' };
|
||||
Intl.DisplayNames = class DisplayNames {
|
||||
of(regionCode) {
|
||||
return regionCode;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const helpers = api.createPhoneAuthHelpers({
|
||||
fillInput: (element, value) => {
|
||||
element.value = value;
|
||||
},
|
||||
getActionText: () => '',
|
||||
getPageTextSnapshot: () => '',
|
||||
getVerificationErrorText: () => '',
|
||||
humanPause: async () => {},
|
||||
isActionEnabled: () => true,
|
||||
isAddPhonePageReady: () => true,
|
||||
isConsentReady: () => false,
|
||||
isPhoneVerificationPageReady: () => phoneVerificationReady,
|
||||
isVisibleElement: () => true,
|
||||
simulateClick: (element) => {
|
||||
element.click?.();
|
||||
phoneVerificationReady = true;
|
||||
global.location.href = 'https://auth.openai.com/phone-verification';
|
||||
},
|
||||
sleep: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
waitForElement: async () => null,
|
||||
});
|
||||
|
||||
const result = await helpers.submitPhoneNumber({
|
||||
countryLabel: 'Country #16',
|
||||
phoneNumber: '+447999221823',
|
||||
});
|
||||
|
||||
assert.equal(dom.select.value, 'GB');
|
||||
assert.equal(dom.phoneInput.value, '7999221823');
|
||||
assert.equal(dom.hiddenPhoneInput.value, '+447999221823');
|
||||
assert.equal(dom.wasSubmitClicked(), true);
|
||||
assert.deepStrictEqual(result, {
|
||||
phoneVerificationPage: true,
|
||||
displayedPhone: '',
|
||||
url: 'https://auth.openai.com/phone-verification',
|
||||
});
|
||||
} finally {
|
||||
global.document = originalDocument;
|
||||
global.Event = originalEvent;
|
||||
global.location = originalLocation;
|
||||
Intl.DisplayNames = OriginalDisplayNames;
|
||||
}
|
||||
});
|
||||
|
||||
test('phone auth can auto-select country by dial code even when number has no plus prefix', async () => {
|
||||
const originalDocument = global.document;
|
||||
const originalEvent = global.Event;
|
||||
const originalLocation = global.location;
|
||||
const OriginalDisplayNames = Intl.DisplayNames;
|
||||
|
||||
const dom = createFakeAddPhoneDom({
|
||||
options: [
|
||||
{ value: 'CO', textContent: 'Colombia (+57)', buttonText: 'Colombia (+57)' },
|
||||
{ value: 'GB', textContent: 'United Kingdom (+44)', buttonText: 'United Kingdom (+44)' },
|
||||
],
|
||||
selectedIndex: 0,
|
||||
});
|
||||
let phoneVerificationReady = false;
|
||||
|
||||
global.document = dom.document;
|
||||
global.Event = class Event {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
};
|
||||
global.location = { href: 'https://auth.openai.com/add-phone' };
|
||||
Intl.DisplayNames = class DisplayNames {
|
||||
of(regionCode) {
|
||||
return regionCode;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const helpers = api.createPhoneAuthHelpers({
|
||||
fillInput: (element, value) => {
|
||||
element.value = value;
|
||||
},
|
||||
getActionText: () => '',
|
||||
getPageTextSnapshot: () => '',
|
||||
getVerificationErrorText: () => '',
|
||||
humanPause: async () => {},
|
||||
isActionEnabled: () => true,
|
||||
isAddPhonePageReady: () => true,
|
||||
isConsentReady: () => false,
|
||||
isPhoneVerificationPageReady: () => phoneVerificationReady,
|
||||
isVisibleElement: () => true,
|
||||
simulateClick: (element) => {
|
||||
element.click?.();
|
||||
phoneVerificationReady = true;
|
||||
global.location.href = 'https://auth.openai.com/phone-verification';
|
||||
},
|
||||
sleep: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
waitForElement: async () => null,
|
||||
});
|
||||
|
||||
const result = await helpers.submitPhoneNumber({
|
||||
countryLabel: 'Country #16',
|
||||
phoneNumber: '447999221823',
|
||||
});
|
||||
|
||||
assert.equal(dom.select.value, 'GB');
|
||||
assert.equal(dom.phoneInput.value, '7999221823');
|
||||
assert.equal(dom.hiddenPhoneInput.value, '+447999221823');
|
||||
assert.equal(dom.wasSubmitClicked(), true);
|
||||
assert.deepStrictEqual(result, {
|
||||
phoneVerificationPage: true,
|
||||
displayedPhone: '',
|
||||
url: 'https://auth.openai.com/phone-verification',
|
||||
});
|
||||
} finally {
|
||||
global.document = originalDocument;
|
||||
global.Event = originalEvent;
|
||||
global.location = originalLocation;
|
||||
Intl.DisplayNames = OriginalDisplayNames;
|
||||
}
|
||||
});
|
||||
|
||||
test('phone auth resend stops with PHONE_ROUTE_405_RECOVERY_FAILED instead of endless Try-again loop', async () => {
|
||||
const originalDocument = global.document;
|
||||
const originalLocation = global.location;
|
||||
const originalWindow = global.window;
|
||||
|
||||
let retryClicks = 0;
|
||||
const fakeRetryButton = {
|
||||
getAttribute(name) {
|
||||
if (name === 'data-dd-action-name') return 'Try again';
|
||||
return '';
|
||||
},
|
||||
click() {
|
||||
retryClicks += 1;
|
||||
},
|
||||
textContent: 'Try again',
|
||||
};
|
||||
const fakeResendButton = {
|
||||
getAttribute(name) {
|
||||
if (name === 'value') return 'resend';
|
||||
return '';
|
||||
},
|
||||
textContent: 'Resend text message',
|
||||
};
|
||||
const fakePhoneForm = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'button, input[type="submit"], input[type="button"]') {
|
||||
return [fakeResendButton, fakeRetryButton];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
global.document = {
|
||||
title: 'Route Error',
|
||||
querySelector(selector) {
|
||||
if (selector === 'button[data-dd-action-name="Try again"]') {
|
||||
return fakeRetryButton;
|
||||
}
|
||||
if (selector === 'form[action*="/phone-verification" i]') {
|
||||
return fakePhoneForm;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'button, [role="button"], input[type="submit"], input[type="button"]') {
|
||||
return [fakeRetryButton, fakeResendButton];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
global.location = {
|
||||
href: 'https://auth.openai.com/phone-verification',
|
||||
pathname: '/phone-verification',
|
||||
};
|
||||
global.window = global;
|
||||
|
||||
const helpers = api.createPhoneAuthHelpers({
|
||||
fillInput: () => {},
|
||||
getActionText: (element) => String(element?.textContent || ''),
|
||||
getPageTextSnapshot: () => (
|
||||
'Route Error (405 Method Not Allowed): You made a POST request to "/phone-verification" but did not provide an action.'
|
||||
),
|
||||
getVerificationErrorText: () => '',
|
||||
humanPause: async () => {},
|
||||
isActionEnabled: () => true,
|
||||
isAddPhonePageReady: () => false,
|
||||
isConsentReady: () => false,
|
||||
isPhoneVerificationPageReady: () => true,
|
||||
isVisibleElement: () => true,
|
||||
simulateClick: (element) => {
|
||||
element?.click?.();
|
||||
},
|
||||
sleep: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
waitForElement: async () => null,
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => helpers.resendPhoneVerificationCode(4000),
|
||||
/PHONE_ROUTE_405_RECOVERY_FAILED::/i
|
||||
);
|
||||
assert.ok(
|
||||
retryClicks > 0 && retryClicks <= 6,
|
||||
`expected bounded retry clicks (1..6), got ${retryClicks}`
|
||||
);
|
||||
} finally {
|
||||
global.document = originalDocument;
|
||||
global.location = originalLocation;
|
||||
global.window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,13 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
|
||||
assert.match(html, /id="btn-toggle-phone-verification-section"/);
|
||||
assert.match(html, /id="row-phone-verification-fold"/);
|
||||
assert.match(html, /id="input-phone-verification-enabled"/);
|
||||
assert.match(html, /id="row-phone-sms-provider"/);
|
||||
assert.match(html, /id="select-phone-sms-provider"/);
|
||||
assert.match(html, /id="row-phone-sms-provider-order"/);
|
||||
assert.match(html, /id="select-phone-sms-provider-order"[^>]*multiple/);
|
||||
assert.match(html, /id="btn-phone-sms-provider-order-menu"/);
|
||||
assert.match(html, /id="row-phone-sms-provider-order-actions"/);
|
||||
assert.match(html, /id="btn-phone-sms-provider-order-reset"/);
|
||||
assert.match(html, /id="row-hero-sms-platform"/);
|
||||
assert.match(html, /id="row-hero-sms-country"/);
|
||||
assert.match(html, /id="row-hero-sms-country-fallback"/);
|
||||
@@ -69,32 +76,83 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
|
||||
assert.match(html, /id="row-hero-sms-api-key"/);
|
||||
assert.match(html, /id="row-hero-sms-max-price"/);
|
||||
assert.match(html, /id="row-hero-sms-current-number"/);
|
||||
assert.match(html, /id="row-hero-sms-current-countdown"/);
|
||||
assert.match(html, /id="row-hero-sms-price-tiers"/);
|
||||
assert.match(html, /id="row-hero-sms-current-code"/);
|
||||
assert.match(html, /id="row-hero-sms-preferred-activation"/);
|
||||
assert.match(html, /id="select-hero-sms-preferred-activation"/);
|
||||
assert.match(html, /id="row-phone-replacement-limit"/);
|
||||
assert.match(html, /id="row-phone-verification-resend-count"/);
|
||||
assert.match(html, /id="row-phone-code-wait-seconds"/);
|
||||
assert.match(html, /id="row-phone-code-timeout-windows"/);
|
||||
assert.match(html, /id="row-phone-code-poll-interval-seconds"/);
|
||||
assert.match(html, /id="row-phone-code-poll-max-rounds"/);
|
||||
assert.match(html, /id="row-oauth-flow-timeout"/);
|
||||
assert.match(html, /id="input-oauth-flow-timeout-enabled"/);
|
||||
assert.match(html, /只取消 Step 7 后链总预算/);
|
||||
assert.match(html, /id="row-five-sim-api-key"/);
|
||||
assert.match(html, /id="input-five-sim-api-key"/);
|
||||
assert.match(html, /id="row-five-sim-country"/);
|
||||
assert.match(html, /id="select-five-sim-country"[^>]*multiple/);
|
||||
assert.match(html, /id="row-five-sim-country-fallback"/);
|
||||
assert.match(html, /id="row-five-sim-operator"/);
|
||||
assert.match(html, /id="input-five-sim-operator"/);
|
||||
assert.match(html, /id="row-five-sim-product"/);
|
||||
assert.match(html, /id="input-five-sim-product"/);
|
||||
assert.match(html, /<option value="nexsms">/);
|
||||
assert.match(html, /id="row-nex-sms-api-key"/);
|
||||
assert.match(html, /id="input-nex-sms-api-key"/);
|
||||
assert.match(html, /id="row-nex-sms-country"/);
|
||||
assert.match(html, /id="select-nex-sms-country"[^>]*multiple/);
|
||||
assert.match(html, /id="row-nex-sms-country-fallback"/);
|
||||
assert.match(html, /id="row-nex-sms-service-code"/);
|
||||
assert.match(html, /id="input-nex-sms-service-code"/);
|
||||
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
|
||||
});
|
||||
|
||||
test('updatePhoneVerificationSettingsUI toggles HeroSMS rows from the sms switch', () => {
|
||||
const api = new Function(`
|
||||
const phoneVerificationSectionExpanded = true;
|
||||
let latestState = {};
|
||||
const inputPhoneVerificationEnabled = { checked: false };
|
||||
const rowPhoneVerificationEnabled = { style: { display: 'none' } };
|
||||
const rowPhoneVerificationFold = { style: { display: 'none' } };
|
||||
const rowPhoneSmsProvider = { style: { display: 'none' } };
|
||||
const rowPhoneSmsProviderOrder = { style: { display: 'none' } };
|
||||
const rowPhoneSmsProviderOrderActions = { style: { display: 'none' } };
|
||||
const selectPhoneSmsProvider = { value: 'hero-sms' };
|
||||
const btnTogglePhoneVerificationSection = {
|
||||
disabled: false,
|
||||
textContent: '',
|
||||
title: '',
|
||||
setAttribute: () => {},
|
||||
};
|
||||
const DEFAULT_PHONE_SMS_PROVIDER_ORDER = ['hero-sms', '5sim', 'nexsms'];
|
||||
const phoneSmsProviderOrderSelection = [];
|
||||
function normalizePhoneSmsProviderOrderValue(value = [], fallbackOrder = DEFAULT_PHONE_SMS_PROVIDER_ORDER) {
|
||||
const source = Array.isArray(value) ? value : [];
|
||||
const normalized = [...source];
|
||||
if (normalized.length) {
|
||||
return normalized.slice(0, 3);
|
||||
}
|
||||
if (!Array.isArray(fallbackOrder) || !fallbackOrder.length) {
|
||||
return [];
|
||||
}
|
||||
const fallbackNormalized = [];
|
||||
for (const provider of fallbackOrder) {
|
||||
if (!fallbackNormalized.includes(provider)) {
|
||||
fallbackNormalized.push(provider);
|
||||
}
|
||||
}
|
||||
return fallbackNormalized.slice(0, 3);
|
||||
}
|
||||
function resolveNormalizedProviderOrderForRuntime(state = {}) {
|
||||
const rawOrder = Array.isArray(state?.phoneSmsProviderOrder) ? state.phoneSmsProviderOrder : [];
|
||||
const normalizedOrder = normalizePhoneSmsProviderOrderValue(rawOrder, []);
|
||||
if (normalizedOrder.length) {
|
||||
return normalizedOrder;
|
||||
}
|
||||
const fallbackProvider = String(state?.phoneSmsProvider || selectPhoneSmsProvider?.value || 'hero-sms').trim().toLowerCase() || 'hero-sms';
|
||||
return [fallbackProvider];
|
||||
}
|
||||
function updatePhoneSmsProviderOrderSummary() {}
|
||||
const rowHeroSmsPlatform = { style: { display: 'none' } };
|
||||
const rowHeroSmsCountry = { style: { display: 'none' } };
|
||||
const rowHeroSmsCountryFallback = { style: { display: 'none' } };
|
||||
@@ -102,20 +160,36 @@ const rowHeroSmsAcquirePriority = { style: { display: 'none' } };
|
||||
const rowHeroSmsApiKey = { style: { display: 'none' } };
|
||||
const rowHeroSmsMaxPrice = { style: { display: 'none' } };
|
||||
const rowHeroSmsCurrentNumber = { style: { display: 'none' } };
|
||||
const rowHeroSmsCurrentCountdown = { style: { display: 'none' } };
|
||||
const rowHeroSmsPriceTiers = { style: { display: 'none' } };
|
||||
const rowHeroSmsCurrentCode = { style: { display: 'none' } };
|
||||
const rowHeroSmsPreferredActivation = { style: { display: 'none' } };
|
||||
const rowPhoneVerificationResendCount = { style: { display: 'none' } };
|
||||
const rowPhoneReplacementLimit = { style: { display: 'none' } };
|
||||
const rowPhoneCodeWaitSeconds = { style: { display: 'none' } };
|
||||
const rowPhoneCodeTimeoutWindows = { style: { display: 'none' } };
|
||||
const rowPhoneCodePollIntervalSeconds = { style: { display: 'none' } };
|
||||
const rowPhoneCodePollMaxRounds = { style: { display: 'none' } };
|
||||
const rowFiveSimApiKey = { style: { display: 'none' } };
|
||||
const rowFiveSimCountry = { style: { display: 'none' } };
|
||||
const rowFiveSimCountryFallback = { style: { display: 'none' } };
|
||||
const rowFiveSimOperator = { style: { display: 'none' } };
|
||||
const rowFiveSimProduct = { style: { display: 'none' } };
|
||||
const rowNexSmsApiKey = { style: { display: 'none' } };
|
||||
const rowNexSmsCountry = { style: { display: 'none' } };
|
||||
const rowNexSmsCountryFallback = { style: { display: 'none' } };
|
||||
const rowNexSmsServiceCode = { style: { display: 'none' } };
|
||||
|
||||
${extractFunction('updatePhoneVerificationSettingsUI')}
|
||||
|
||||
return {
|
||||
setLatestState: (state) => { latestState = state || {}; },
|
||||
rowPhoneVerificationEnabled,
|
||||
rowPhoneVerificationFold,
|
||||
rowPhoneSmsProvider,
|
||||
rowPhoneSmsProviderOrder,
|
||||
rowPhoneSmsProviderOrderActions,
|
||||
selectPhoneSmsProvider,
|
||||
btnTogglePhoneVerificationSection,
|
||||
inputPhoneVerificationEnabled,
|
||||
rowHeroSmsPlatform,
|
||||
@@ -125,14 +199,25 @@ return {
|
||||
rowHeroSmsApiKey,
|
||||
rowHeroSmsMaxPrice,
|
||||
rowHeroSmsCurrentNumber,
|
||||
rowHeroSmsCurrentCountdown,
|
||||
rowHeroSmsPriceTiers,
|
||||
rowHeroSmsCurrentCode,
|
||||
rowHeroSmsPreferredActivation,
|
||||
rowPhoneVerificationResendCount,
|
||||
rowPhoneReplacementLimit,
|
||||
rowPhoneCodeWaitSeconds,
|
||||
rowPhoneCodeTimeoutWindows,
|
||||
rowPhoneCodePollIntervalSeconds,
|
||||
rowPhoneCodePollMaxRounds,
|
||||
rowFiveSimApiKey,
|
||||
rowFiveSimCountry,
|
||||
rowFiveSimCountryFallback,
|
||||
rowFiveSimOperator,
|
||||
rowFiveSimProduct,
|
||||
rowNexSmsApiKey,
|
||||
rowNexSmsCountry,
|
||||
rowNexSmsCountryFallback,
|
||||
rowNexSmsServiceCode,
|
||||
updatePhoneVerificationSettingsUI,
|
||||
};
|
||||
`)();
|
||||
@@ -140,6 +225,9 @@ return {
|
||||
api.updatePhoneVerificationSettingsUI();
|
||||
assert.equal(api.rowPhoneVerificationEnabled.style.display, '');
|
||||
assert.equal(api.rowPhoneVerificationFold.style.display, 'none');
|
||||
assert.equal(api.rowPhoneSmsProvider.style.display, 'none');
|
||||
assert.equal(api.rowPhoneSmsProviderOrder.style.display, 'none');
|
||||
assert.equal(api.rowPhoneSmsProviderOrderActions.style.display, 'none');
|
||||
assert.equal(api.btnTogglePhoneVerificationSection.disabled, true);
|
||||
assert.equal(api.btnTogglePhoneVerificationSection.textContent, '展开设置');
|
||||
assert.equal(api.rowHeroSmsPlatform.style.display, 'none');
|
||||
@@ -149,18 +237,32 @@ return {
|
||||
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
|
||||
assert.equal(api.rowHeroSmsMaxPrice.style.display, 'none');
|
||||
assert.equal(api.rowHeroSmsCurrentNumber.style.display, 'none');
|
||||
assert.equal(api.rowHeroSmsCurrentCountdown.style.display, 'none');
|
||||
assert.equal(api.rowHeroSmsPriceTiers.style.display, 'none');
|
||||
assert.equal(api.rowHeroSmsCurrentCode.style.display, 'none');
|
||||
assert.equal(api.rowHeroSmsPreferredActivation.style.display, 'none');
|
||||
assert.equal(api.rowPhoneVerificationResendCount.style.display, 'none');
|
||||
assert.equal(api.rowPhoneReplacementLimit.style.display, 'none');
|
||||
assert.equal(api.rowPhoneCodeWaitSeconds.style.display, 'none');
|
||||
assert.equal(api.rowPhoneCodeTimeoutWindows.style.display, 'none');
|
||||
assert.equal(api.rowPhoneCodePollIntervalSeconds.style.display, 'none');
|
||||
assert.equal(api.rowPhoneCodePollMaxRounds.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimApiKey.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimCountry.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimCountryFallback.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimOperator.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimProduct.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsApiKey.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsCountry.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsCountryFallback.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
|
||||
|
||||
api.inputPhoneVerificationEnabled.checked = true;
|
||||
api.updatePhoneVerificationSettingsUI();
|
||||
assert.equal(api.rowPhoneVerificationFold.style.display, '');
|
||||
assert.equal(api.rowPhoneSmsProvider.style.display, '');
|
||||
assert.equal(api.rowPhoneSmsProviderOrder.style.display, '');
|
||||
assert.equal(api.rowPhoneSmsProviderOrderActions.style.display, '');
|
||||
assert.equal(api.btnTogglePhoneVerificationSection.disabled, false);
|
||||
assert.equal(api.btnTogglePhoneVerificationSection.textContent, '收起设置');
|
||||
assert.equal(api.rowHeroSmsPlatform.style.display, '');
|
||||
@@ -170,14 +272,51 @@ return {
|
||||
assert.equal(api.rowHeroSmsApiKey.style.display, '');
|
||||
assert.equal(api.rowHeroSmsMaxPrice.style.display, '');
|
||||
assert.equal(api.rowHeroSmsCurrentNumber.style.display, '');
|
||||
assert.equal(api.rowHeroSmsCurrentCountdown.style.display, '');
|
||||
assert.equal(api.rowHeroSmsPriceTiers.style.display, 'none');
|
||||
assert.equal(api.rowHeroSmsCurrentCode.style.display, '');
|
||||
assert.equal(api.rowHeroSmsPreferredActivation.style.display, '');
|
||||
assert.equal(api.rowPhoneVerificationResendCount.style.display, '');
|
||||
assert.equal(api.rowPhoneReplacementLimit.style.display, '');
|
||||
assert.equal(api.rowPhoneCodeWaitSeconds.style.display, '');
|
||||
assert.equal(api.rowPhoneCodeTimeoutWindows.style.display, '');
|
||||
assert.equal(api.rowPhoneCodePollIntervalSeconds.style.display, '');
|
||||
assert.equal(api.rowPhoneCodePollMaxRounds.style.display, '');
|
||||
assert.equal(api.rowFiveSimApiKey.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimCountry.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimCountryFallback.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimOperator.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimProduct.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsApiKey.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsCountry.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsCountryFallback.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
|
||||
|
||||
api.selectPhoneSmsProvider.value = '5sim';
|
||||
api.setLatestState({ phoneSmsProviderOrder: ['5sim'] });
|
||||
api.updatePhoneVerificationSettingsUI();
|
||||
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
|
||||
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimApiKey.style.display, '');
|
||||
assert.equal(api.rowFiveSimCountry.style.display, '');
|
||||
assert.equal(api.rowFiveSimCountryFallback.style.display, '');
|
||||
assert.equal(api.rowFiveSimOperator.style.display, '');
|
||||
assert.equal(api.rowFiveSimProduct.style.display, '');
|
||||
assert.equal(api.rowNexSmsApiKey.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsCountry.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsCountryFallback.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
|
||||
|
||||
api.selectPhoneSmsProvider.value = 'nexsms';
|
||||
api.setLatestState({ phoneSmsProviderOrder: ['nexsms'] });
|
||||
api.updatePhoneVerificationSettingsUI();
|
||||
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimApiKey.style.display, 'none');
|
||||
assert.equal(api.rowFiveSimCountry.style.display, 'none');
|
||||
assert.equal(api.rowNexSmsApiKey.style.display, '');
|
||||
assert.equal(api.rowNexSmsCountry.style.display, '');
|
||||
assert.equal(api.rowNexSmsCountryFallback.style.display, '');
|
||||
assert.equal(api.rowNexSmsServiceCode.style.display, '');
|
||||
});
|
||||
|
||||
test('collectSettingsPayload keeps local helper sync enabled while persisting sms toggle state', () => {
|
||||
@@ -186,6 +325,7 @@ let latestState = {
|
||||
contributionMode: false,
|
||||
mail2925UseAccountPool: false,
|
||||
currentMail2925AccountId: '',
|
||||
fiveSimCountryOrder: ['thailand', 'england'],
|
||||
};
|
||||
let cloudflareDomainEditMode = false;
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
@@ -225,12 +365,26 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
|
||||
const inputAutoDelayEnabled = { checked: false };
|
||||
const inputAutoDelayMinutes = { value: '30' };
|
||||
const inputAutoStepDelaySeconds = { value: '' };
|
||||
const inputOAuthFlowTimeoutEnabled = { checked: false };
|
||||
const inputPhoneVerificationEnabled = { checked: true };
|
||||
const selectPhoneSmsProvider = { value: 'hero-sms' };
|
||||
const inputVerificationResendCount = { value: '4' };
|
||||
const inputHeroSmsApiKey = { value: 'demo-key' };
|
||||
const inputFiveSimApiKey = { value: 'five-sim-key' };
|
||||
const inputFiveSimOperator = { value: 'any' };
|
||||
const inputFiveSimProduct = { value: 'openai' };
|
||||
const inputHeroSmsReuseEnabled = { checked: true };
|
||||
const selectHeroSmsAcquirePriority = { value: 'price' };
|
||||
function getSelectedPhonePreferredActivation() {
|
||||
return {
|
||||
provider: 'hero-sms',
|
||||
activationId: 'demo-activation',
|
||||
phoneNumber: '66958889999',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
};
|
||||
}
|
||||
const inputHeroSmsMaxPrice = { value: '0.12' };
|
||||
const inputPhoneReplacementLimit = { value: '5' };
|
||||
const inputPhoneCodeWaitSeconds = { value: '75' };
|
||||
@@ -303,13 +457,26 @@ return { collectSettingsPayload };
|
||||
const payload = api.collectSettingsPayload();
|
||||
|
||||
assert.equal(payload.phoneVerificationEnabled, true);
|
||||
assert.equal(payload.oauthFlowTimeoutEnabled, false);
|
||||
assert.equal(payload.phoneSmsProvider, 'hero-sms');
|
||||
assert.equal(payload.accountRunHistoryTextEnabled, true);
|
||||
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
|
||||
assert.equal(payload.heroSmsApiKey, 'demo-key');
|
||||
assert.equal(payload.fiveSimApiKey, 'five-sim-key');
|
||||
assert.deepStrictEqual(payload.fiveSimCountryOrder, ['thailand', 'england']);
|
||||
assert.equal(payload.fiveSimOperator, 'any');
|
||||
assert.equal(payload.fiveSimProduct, 'openai');
|
||||
assert.equal(payload.heroSmsReuseEnabled, true);
|
||||
assert.equal(payload.heroSmsAcquirePriority, 'price');
|
||||
assert.equal(payload.heroSmsMaxPrice, '0.12');
|
||||
assert.deepStrictEqual(payload.phonePreferredActivation, {
|
||||
provider: 'hero-sms',
|
||||
activationId: 'demo-activation',
|
||||
phoneNumber: '66958889999',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
});
|
||||
assert.equal(payload.phoneVerificationReplacementLimit, 5);
|
||||
assert.equal(payload.phoneCodeWaitSeconds, 75);
|
||||
assert.equal(payload.phoneCodeTimeoutWindows, 3);
|
||||
@@ -319,3 +486,14 @@ return { collectSettingsPayload };
|
||||
assert.equal(payload.heroSmsCountryLabel, 'Thailand');
|
||||
assert.deepStrictEqual(payload.heroSmsCountryFallback, [{ id: 16, label: 'United Kingdom' }]);
|
||||
});
|
||||
|
||||
test('hero sms max price input does not auto-save partial typing states', () => {
|
||||
assert.match(
|
||||
sidepanelSource,
|
||||
/inputHeroSmsMaxPrice\?\.\s*addEventListener\('input',\s*\(\)\s*=>\s*\{\s*markSettingsDirty\(true\);\s*\}\);/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
sidepanelSource,
|
||||
/inputHeroSmsMaxPrice\?\.\s*addEventListener\('input',\s*\(\)\s*=>\s*\{\s*markSettingsDirty\(true\);\s*scheduleSettingsAutoSave\(\);/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const asyncStart = sidepanelSource.indexOf(`async function ${name}`);
|
||||
const normalStart = sidepanelSource.indexOf(`function ${name}`);
|
||||
const start = asyncStart !== -1
|
||||
? asyncStart
|
||||
: normalStart;
|
||||
if (start === -1) {
|
||||
throw new Error(`Function ${name} not found`);
|
||||
}
|
||||
const signatureEnd = sidepanelSource.indexOf(')', start);
|
||||
const bodyStart = sidepanelSource.indexOf('{', signatureEnd);
|
||||
let depth = 0;
|
||||
let end = bodyStart;
|
||||
for (; end < sidepanelSource.length; end += 1) {
|
||||
const char = sidepanelSource[end];
|
||||
if (char === '{') {
|
||||
depth += 1;
|
||||
} else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sidepanelSource.slice(start, end);
|
||||
}
|
||||
|
||||
test('sidepanel step definitions keep the selected Plus payment method', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizePlusPaymentMethod'),
|
||||
extractFunction('getStepDefinitionsForMode'),
|
||||
extractFunction('rebuildStepDefinitionState'),
|
||||
extractFunction('syncStepDefinitionsForMode'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const calls = [];
|
||||
const window = {
|
||||
MultiPageStepDefinitions: {
|
||||
getSteps(options) {
|
||||
calls.push({ type: 'getSteps', options });
|
||||
return [{ id: options.plusPaymentMethod === 'gopay' ? 7 : 6, order: 1 }];
|
||||
},
|
||||
},
|
||||
};
|
||||
let currentPlusModeEnabled = false;
|
||||
let currentPlusPaymentMethod = 'paypal';
|
||||
let stepDefinitions = [];
|
||||
let STEP_IDS = [];
|
||||
let STEP_DEFAULT_STATUSES = {};
|
||||
let SKIPPABLE_STEPS = new Set();
|
||||
function renderStepsList() {
|
||||
calls.push({ type: 'render', stepIds: [...STEP_IDS] });
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
calls,
|
||||
syncStepDefinitionsForMode,
|
||||
getCurrentPlusPaymentMethod: () => currentPlusPaymentMethod,
|
||||
getStepIds: () => [...STEP_IDS],
|
||||
};
|
||||
`)();
|
||||
|
||||
api.syncStepDefinitionsForMode(true, 'gopay', { render: true });
|
||||
|
||||
assert.equal(api.getCurrentPlusPaymentMethod(), 'gopay');
|
||||
assert.deepEqual(api.getStepIds(), [7]);
|
||||
assert.deepEqual(api.calls[0], {
|
||||
type: 'getSteps',
|
||||
options: { plusModeEnabled: true, plusPaymentMethod: 'gopay' },
|
||||
});
|
||||
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [7] });
|
||||
});
|
||||
|
||||
test('sidepanel Plus UI hides PayPal account selector while GoPay is selected', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizePlusPaymentMethod'),
|
||||
extractFunction('getSelectedPlusPaymentMethod'),
|
||||
extractFunction('updatePlusModeUI'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = { plusPaymentMethod: 'gopay' };
|
||||
let currentPlusPaymentMethod = 'paypal';
|
||||
const inputPlusModeEnabled = { checked: true };
|
||||
const selectPlusPaymentMethod = { value: 'gopay', style: { display: 'none' } };
|
||||
const rowPayPalAccount = { style: { display: '' } };
|
||||
${bundle}
|
||||
return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount };
|
||||
`)();
|
||||
|
||||
api.updatePlusModeUI();
|
||||
|
||||
assert.equal(api.selectPlusPaymentMethod.style.display, '');
|
||||
assert.equal(api.rowPayPalAccount.style.display, 'none');
|
||||
|
||||
api.selectPlusPaymentMethod.value = 'paypal';
|
||||
api.updatePlusModeUI();
|
||||
assert.equal(api.rowPayPalAccount.style.display, '');
|
||||
});
|
||||
|
||||
test('sidepanel resolves pending GoPay manual confirmation from DATA_UPDATED state', async () => {
|
||||
const bundle = [
|
||||
extractFunction('openPlusManualConfirmationDialog'),
|
||||
extractFunction('syncPlusManualConfirmationDialog'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const events = [];
|
||||
let latestState = {
|
||||
plusManualConfirmationPending: true,
|
||||
plusManualConfirmationRequestId: 'gopay-request-1',
|
||||
plusManualConfirmationStep: 7,
|
||||
plusManualConfirmationMethod: 'gopay',
|
||||
plusManualConfirmationTitle: 'GoPay 订阅确认',
|
||||
plusManualConfirmationMessage: '请确认订阅。',
|
||||
};
|
||||
let activePlusManualConfirmationRequestId = '';
|
||||
let plusManualConfirmationDialogInFlight = false;
|
||||
function openActionModal(options) {
|
||||
events.push({ type: 'modal', options });
|
||||
return Promise.resolve('confirm');
|
||||
}
|
||||
function showToast(message, tone) {
|
||||
events.push({ type: 'toast', message, tone });
|
||||
}
|
||||
const chrome = {
|
||||
runtime: {
|
||||
async sendMessage(message) {
|
||||
events.push({ type: 'send', message });
|
||||
latestState = {
|
||||
...latestState,
|
||||
plusManualConfirmationPending: false,
|
||||
};
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
${bundle}
|
||||
return { events, syncPlusManualConfirmationDialog };
|
||||
`)();
|
||||
|
||||
await api.syncPlusManualConfirmationDialog();
|
||||
|
||||
assert.equal(api.events[0].type, 'modal');
|
||||
assert.equal(api.events[0].options.title, 'GoPay 订阅确认');
|
||||
assert.deepEqual(api.events[1], {
|
||||
type: 'send',
|
||||
message: {
|
||||
type: 'RESOLVE_PLUS_MANUAL_CONFIRMATION',
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
step: 7,
|
||||
requestId: 'gopay-request-1',
|
||||
confirmed: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.match(api.events[2].message, /GoPay/);
|
||||
assert.equal(api.events[2].tone, 'info');
|
||||
});
|
||||
@@ -253,3 +253,54 @@ return {
|
||||
state: 'step5',
|
||||
});
|
||||
});
|
||||
|
||||
test('signup verification state keeps verification priority when email-verification page also shows profile fields', () => {
|
||||
const api = new Function(`
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/email-verification/register',
|
||||
};
|
||||
|
||||
function isStep5Ready() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isVerificationPageStillVisible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSignupPasswordErrorPage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSignupPasswordTimeoutErrorPageState() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSignupEmailAlreadyExistsPage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSignupPasswordInput() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSignupPasswordSubmitButton() {
|
||||
return null;
|
||||
}
|
||||
|
||||
${extractFunction('isSignupProfilePageUrl')}
|
||||
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
|
||||
${extractFunction('getStep4PostVerificationState')}
|
||||
${extractFunction('inspectSignupVerificationState')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return inspectSignupVerificationState();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.deepStrictEqual(api.run(), {
|
||||
state: 'verification',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,8 @@ async function sleep() {}
|
||||
function isStep5Ready() { return false; }
|
||||
function isStep8Ready() { return false; }
|
||||
function isAddPhonePageReady() { return false; }
|
||||
function isVerificationPageStillVisible() { return false; }
|
||||
function isEmailVerificationPage() { return true; }
|
||||
function isVisibleElement() { return true; }
|
||||
function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
|
||||
function getActionText(el) { return el.textContent || ''; }
|
||||
@@ -131,6 +133,8 @@ ${extractFunction('getVerificationSubmitButtonForTarget')}
|
||||
${extractFunction('waitForVerificationSubmitButton')}
|
||||
${extractFunction('waitForVerificationCodeTarget')}
|
||||
${extractFunction('waitForSplitVerificationInputsFilled')}
|
||||
${extractFunction('isCombinedSignupVerificationProfilePage')}
|
||||
${extractFunction('waitForCombinedSignupVerificationProfilePage')}
|
||||
${extractFunction('isSignupProfilePageUrl')}
|
||||
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
|
||||
${extractFunction('getStep4PostVerificationState')}
|
||||
@@ -160,3 +164,453 @@ return {
|
||||
assert.equal(snapshot.submitClicked, true);
|
||||
assert.deepStrictEqual(snapshot.clicks, ['Continue']);
|
||||
});
|
||||
|
||||
test('fillVerificationCode does not short-circuit on mixed email-verification profile page before verification exits', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
const location = { href: 'https://auth.openai.com/email-verification/register' };
|
||||
|
||||
function throwIfStopped() {}
|
||||
function log(message, level = 'info') { logs.push({ message, level }); }
|
||||
async function waitForLoginVerificationPageReady() {}
|
||||
function is405MethodNotAllowedPage() { return false; }
|
||||
async function handle405ResendError() {}
|
||||
function fillInput() {}
|
||||
async function sleep() {}
|
||||
function isStep5Ready() { return true; }
|
||||
function isStep8Ready() { return false; }
|
||||
function isAddPhonePageReady() { return false; }
|
||||
function isVisibleElement() { return true; }
|
||||
function isActionEnabled() { return true; }
|
||||
function getActionText(el) { return el?.textContent || ''; }
|
||||
async function humanPause() {}
|
||||
function simulateClick() {}
|
||||
function getCurrentAuthRetryPageState() { return null; }
|
||||
function isPhoneVerificationPageReady() { return false; }
|
||||
function getVerificationCodeTarget() { return { type: 'single', element: { value: '', form: null, closest() { return null; } } }; }
|
||||
function findResendVerificationCodeTrigger() { return { textContent: 'Resend' }; }
|
||||
function isEmailVerificationPage() { return true; }
|
||||
function getPageTextSnapshot() { return 'Enter the verification code we just sent'; }
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
if (selector === 'form[action*="email-verification" i]') return {};
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
${extractFunction('isVerificationPageStillVisible')}
|
||||
${extractFunction('isCombinedSignupVerificationProfilePage')}
|
||||
${extractFunction('waitForCombinedSignupVerificationProfilePage')}
|
||||
${extractFunction('isSignupProfilePageUrl')}
|
||||
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
|
||||
${extractFunction('getStep4PostVerificationState')}
|
||||
${extractFunction('fillVerificationCode')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
try {
|
||||
await fillVerificationCode(4, { code: '123456' });
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, message: String(error?.message || error) };
|
||||
}
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
assert.deepStrictEqual(result, {
|
||||
ok: false,
|
||||
message: '未找到验证码输入框。URL: https://auth.openai.com/email-verification/register',
|
||||
});
|
||||
});
|
||||
|
||||
test('fillVerificationCode prefills profile on combined verification/register page and skips step 5 after success', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
const clicks = [];
|
||||
const filledValues = [];
|
||||
let submitClicked = false;
|
||||
const VERIFICATION_CODE_INPUT_SELECTOR = 'input[name="code"]';
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/email-verification/register',
|
||||
pathname: '/email-verification/register',
|
||||
};
|
||||
function KeyboardEvent(type, init = {}) {
|
||||
this.type = type;
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
const nameInput = {
|
||||
value: '',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'name') return 'name';
|
||||
if (name === 'autocomplete') return 'name';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const ageInput = {
|
||||
value: '',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'name') return 'age';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const codeInput = {
|
||||
value: '',
|
||||
disabled: false,
|
||||
form: null,
|
||||
getAttribute(name) {
|
||||
if (name === 'maxlength') return '6';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
if (name === 'name') return 'code';
|
||||
return '';
|
||||
},
|
||||
closest() { return null; },
|
||||
focus() {},
|
||||
dispatchEvent() {},
|
||||
};
|
||||
const submitBtn = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Continue',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'submit';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
click() {
|
||||
submitClicked = true;
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
switch (selector) {
|
||||
case 'input[name="name"], input[autocomplete="name"], input[name="birthday"], input[name="age"], [role="spinbutton"][data-type="year"]':
|
||||
return nameInput;
|
||||
case 'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]':
|
||||
return nameInput;
|
||||
case 'input[name="age"]':
|
||||
return ageInput;
|
||||
case '[role="spinbutton"][data-type="year"]':
|
||||
case '[role="spinbutton"][data-type="month"]':
|
||||
case '[role="spinbutton"][data-type="day"]':
|
||||
case 'input[name="birthday"]':
|
||||
return null;
|
||||
case 'form[action*="email-verification/register" i]':
|
||||
case 'form[action*="email-verification" i]':
|
||||
return { action: '/email-verification/register' };
|
||||
case VERIFICATION_CODE_INPUT_SELECTOR:
|
||||
return codeInput;
|
||||
case 'button[type="submit"]':
|
||||
return submitBtn;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[maxlength="1"]') return [];
|
||||
if (selector === 'button[type="submit"], input[type="submit"]') return [submitBtn];
|
||||
if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [submitBtn];
|
||||
if (selector === 'input[name="allCheckboxes"][type="checkbox"]') return [];
|
||||
if (selector === 'input[type="checkbox"]') return [];
|
||||
return [];
|
||||
},
|
||||
execCommand() {},
|
||||
};
|
||||
|
||||
function throwIfStopped() {}
|
||||
function log(message, level = 'info') { logs.push({ message, level }); }
|
||||
async function waitForLoginVerificationPageReady() {}
|
||||
function is405MethodNotAllowedPage() { return false; }
|
||||
async function handle405ResendError() {}
|
||||
function fillInput(el, value) {
|
||||
el.value = value;
|
||||
filledValues.push({ target: el === nameInput ? 'name' : (el === ageInput ? 'age' : 'code'), value });
|
||||
}
|
||||
async function sleep() {}
|
||||
function isStep5Ready() { return true; }
|
||||
function isStep8Ready() { return false; }
|
||||
function isAddPhonePageReady() { return false; }
|
||||
function isVisibleElement(el) { return Boolean(el) && !el.disabled; }
|
||||
function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
|
||||
function getActionText(el) { return el.textContent || ''; }
|
||||
async function humanPause() {}
|
||||
function simulateClick(el) { el.click(); clicks.push(el.textContent); }
|
||||
function getCurrentAuthRetryPageState() { return null; }
|
||||
function isPhoneVerificationPageReady() { return false; }
|
||||
function findResendVerificationCodeTrigger() { return null; }
|
||||
function isEmailVerificationPage() { return true; }
|
||||
function isCombinedSignupVerificationProfilePage() { return true; }
|
||||
function getPageTextSnapshot() { return 'Create your account Enter the verification code we just sent Tell us about you'; }
|
||||
function findBirthdayReactAriaSelect() { return null; }
|
||||
async function setReactAriaBirthdaySelect() {}
|
||||
async function waitForElement(selector) {
|
||||
if (/input\\[name=\"name\"\\]/.test(selector)) {
|
||||
return nameInput;
|
||||
}
|
||||
throw new Error('unexpected selector ' + selector);
|
||||
}
|
||||
async function waitForElementByText() { return submitBtn; }
|
||||
function isStep5AllConsentText() { return false; }
|
||||
function findStep5AllConsentCheckbox() { return null; }
|
||||
function isStep5CheckboxChecked() { return false; }
|
||||
async function waitForVerificationSubmitOutcome() {
|
||||
return { success: true, skipProfileStep: true };
|
||||
}
|
||||
|
||||
${extractFunction('getStep5DirectCompletionPayload')}
|
||||
${extractFunction('isVerificationPageStillVisible')}
|
||||
${extractFunction('isSignupProfilePageUrl')}
|
||||
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
|
||||
${extractFunction('getStep4PostVerificationState')}
|
||||
${extractFunction('getVisibleSplitVerificationInputs')}
|
||||
${extractFunction('getVerificationCodeTarget')}
|
||||
${extractFunction('getVerificationSubmitButtonForTarget')}
|
||||
${extractFunction('waitForVerificationSubmitButton')}
|
||||
${extractFunction('waitForVerificationCodeTarget')}
|
||||
${extractFunction('waitForSplitVerificationInputsFilled')}
|
||||
${extractFunction('waitForCombinedSignupVerificationProfilePage')}
|
||||
${extractFunction('step5_fillNameBirthday')}
|
||||
${extractFunction('fillVerificationCode')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return fillVerificationCode(4, {
|
||||
code: '123456',
|
||||
signupProfile: {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
age: 22,
|
||||
},
|
||||
});
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
logs,
|
||||
clicks,
|
||||
filledValues,
|
||||
submitClicked,
|
||||
nameValue: nameInput.value,
|
||||
ageValue: ageInput.value,
|
||||
codeValue: codeInput.value,
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
success: true,
|
||||
skipProfileStep: true,
|
||||
skipProfileStepReason: 'combined_verification_profile',
|
||||
});
|
||||
assert.equal(snapshot.nameValue, 'Ada Lovelace');
|
||||
assert.equal(snapshot.ageValue, '22');
|
||||
assert.equal(snapshot.codeValue, '123456');
|
||||
assert.equal(snapshot.submitClicked, true);
|
||||
assert.deepStrictEqual(snapshot.clicks, ['Continue']);
|
||||
});
|
||||
|
||||
test('fillVerificationCode waits for delayed combined profile fields before prefilling', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
const clicks = [];
|
||||
let submitClicked = false;
|
||||
let nameQueryCount = 0;
|
||||
const VERIFICATION_CODE_INPUT_SELECTOR = 'input[name="code"]';
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/email-verification/register',
|
||||
pathname: '/email-verification/register',
|
||||
};
|
||||
function KeyboardEvent(type, init = {}) {
|
||||
this.type = type;
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
const nameInput = {
|
||||
value: '',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'name') return 'name';
|
||||
if (name === 'autocomplete') return 'name';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const ageInput = {
|
||||
value: '',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'name') return 'age';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const codeInput = {
|
||||
value: '',
|
||||
disabled: false,
|
||||
form: null,
|
||||
getAttribute(name) {
|
||||
if (name === 'maxlength') return '6';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
if (name === 'name') return 'code';
|
||||
return '';
|
||||
},
|
||||
closest() { return null; },
|
||||
focus() {},
|
||||
dispatchEvent() {},
|
||||
};
|
||||
const submitBtn = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Continue',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'submit';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
click() {
|
||||
submitClicked = true;
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
switch (selector) {
|
||||
case 'input[name="name"], input[autocomplete="name"]':
|
||||
nameQueryCount += 1;
|
||||
return nameQueryCount >= 3 ? nameInput : null;
|
||||
case 'input[name="name"], input[autocomplete="name"], input[name="birthday"], input[name="age"], [role="spinbutton"][data-type="year"]':
|
||||
nameQueryCount += 1;
|
||||
return nameQueryCount >= 3 ? nameInput : null;
|
||||
case 'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]':
|
||||
return nameInput;
|
||||
case 'input[name="age"]':
|
||||
return nameQueryCount >= 3 ? ageInput : null;
|
||||
case '[role="spinbutton"][data-type="year"]':
|
||||
case '[role="spinbutton"][data-type="month"]':
|
||||
case '[role="spinbutton"][data-type="day"]':
|
||||
case 'input[name="birthday"]':
|
||||
return null;
|
||||
case 'form[action*="email-verification/register" i]':
|
||||
case 'form[action*="email-verification" i]':
|
||||
return { action: '/email-verification/register' };
|
||||
case VERIFICATION_CODE_INPUT_SELECTOR:
|
||||
return codeInput;
|
||||
case 'button[type="submit"]':
|
||||
return submitBtn;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[maxlength="1"]') return [];
|
||||
if (selector === 'button[type="submit"], input[type="submit"]') return [submitBtn];
|
||||
if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [submitBtn];
|
||||
if (selector === 'input[name="allCheckboxes"][type="checkbox"]') return [];
|
||||
if (selector === 'input[type="checkbox"]') return [];
|
||||
return [];
|
||||
},
|
||||
execCommand() {},
|
||||
};
|
||||
|
||||
function throwIfStopped() {}
|
||||
function log(message, level = 'info') { logs.push({ message, level }); }
|
||||
async function waitForLoginVerificationPageReady() {}
|
||||
function is405MethodNotAllowedPage() { return false; }
|
||||
async function handle405ResendError() {}
|
||||
function fillInput(el, value) {
|
||||
el.value = value;
|
||||
}
|
||||
async function sleep() {}
|
||||
function isStep5Ready() { return false; }
|
||||
function isStep8Ready() { return false; }
|
||||
function isAddPhonePageReady() { return false; }
|
||||
function isVisibleElement(el) { return Boolean(el) && !el.disabled; }
|
||||
function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
|
||||
function getActionText(el) { return el.textContent || ''; }
|
||||
async function humanPause() {}
|
||||
function simulateClick(el) { el.click(); clicks.push(el.textContent); }
|
||||
function getCurrentAuthRetryPageState() { return null; }
|
||||
function isPhoneVerificationPageReady() { return false; }
|
||||
function findResendVerificationCodeTrigger() { return null; }
|
||||
function isEmailVerificationPage() { return true; }
|
||||
function getPageTextSnapshot() { return 'Create your account Enter the verification code we just sent Tell us about you'; }
|
||||
function findBirthdayReactAriaSelect() { return null; }
|
||||
async function setReactAriaBirthdaySelect() {}
|
||||
async function waitForElement(selector) {
|
||||
if (/input\\[name=\"name\"\\]/.test(selector)) {
|
||||
return nameInput;
|
||||
}
|
||||
throw new Error('unexpected selector ' + selector);
|
||||
}
|
||||
async function waitForElementByText() { return submitBtn; }
|
||||
function isStep5AllConsentText() { return false; }
|
||||
function findStep5AllConsentCheckbox() { return null; }
|
||||
function isStep5CheckboxChecked() { return false; }
|
||||
async function waitForVerificationSubmitOutcome() {
|
||||
return { success: true, skipProfileStep: true };
|
||||
}
|
||||
|
||||
${extractFunction('getStep5DirectCompletionPayload')}
|
||||
${extractFunction('isVerificationPageStillVisible')}
|
||||
${extractFunction('isCombinedSignupVerificationProfilePage')}
|
||||
${extractFunction('waitForCombinedSignupVerificationProfilePage')}
|
||||
${extractFunction('isSignupProfilePageUrl')}
|
||||
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
|
||||
${extractFunction('getStep4PostVerificationState')}
|
||||
${extractFunction('getVisibleSplitVerificationInputs')}
|
||||
${extractFunction('getVerificationCodeTarget')}
|
||||
${extractFunction('getVerificationSubmitButtonForTarget')}
|
||||
${extractFunction('waitForVerificationSubmitButton')}
|
||||
${extractFunction('waitForVerificationCodeTarget')}
|
||||
${extractFunction('waitForSplitVerificationInputsFilled')}
|
||||
${extractFunction('step5_fillNameBirthday')}
|
||||
${extractFunction('fillVerificationCode')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return fillVerificationCode(4, {
|
||||
code: '123456',
|
||||
signupProfile: {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
age: 22,
|
||||
},
|
||||
});
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
nameValue: nameInput.value,
|
||||
ageValue: ageInput.value,
|
||||
codeValue: codeInput.value,
|
||||
submitClicked,
|
||||
clicks,
|
||||
nameQueryCount,
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
success: true,
|
||||
skipProfileStep: true,
|
||||
skipProfileStepReason: 'combined_verification_profile',
|
||||
});
|
||||
assert.equal(snapshot.nameValue, 'Ada Lovelace');
|
||||
assert.equal(snapshot.ageValue, '22');
|
||||
assert.equal(snapshot.codeValue, '123456');
|
||||
assert.equal(snapshot.submitClicked, true);
|
||||
assert.equal(snapshot.nameQueryCount >= 3, true);
|
||||
});
|
||||
|
||||
@@ -198,3 +198,44 @@ return {
|
||||
url: 'https://chatgpt.com/',
|
||||
});
|
||||
});
|
||||
|
||||
test('waitForVerificationSubmitOutcome treats step 5 as success after submit even when verification ui residue remains', async () => {
|
||||
const api = new Function(`
|
||||
const location = { href: 'https://auth.openai.com/email-verification/register' };
|
||||
|
||||
function throwIfStopped() {}
|
||||
function log() {}
|
||||
function getVerificationErrorText() { return ''; }
|
||||
function isStep5Ready() { return true; }
|
||||
function isStep8Ready() { return false; }
|
||||
function isAddPhonePageReady() { return false; }
|
||||
function isVerificationPageStillVisible() { return true; }
|
||||
function createSignupUserAlreadyExistsError() {
|
||||
return new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
|
||||
}
|
||||
function getCurrentAuthRetryPageState() {
|
||||
return null;
|
||||
}
|
||||
async function recoverCurrentAuthRetryPage() {
|
||||
throw new Error('should not recover retry page');
|
||||
}
|
||||
async function sleep() {}
|
||||
|
||||
${extractFunction('isSignupProfilePageUrl')}
|
||||
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
|
||||
${extractFunction('getStep4PostVerificationState')}
|
||||
${extractFunction('waitForVerificationSubmitOutcome')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return waitForVerificationSubmitOutcome(4, 1000);
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,3 +189,74 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 8 escalates to rerun step 7 after too many local retry_without_step7 recoveries', async () => {
|
||||
const calls = {
|
||||
rerunStep7: 0,
|
||||
ensureReady: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
const executor = step8Api.createStep8Executor({
|
||||
addLog: async (message, level) => {
|
||||
calls.logs.push({ message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async () => {
|
||||
calls.ensureReady += 1;
|
||||
return { state: 'verification_page' };
|
||||
},
|
||||
rerunStep7ForStep8Recovery: async () => {
|
||||
calls.rerunStep7 += 1;
|
||||
throw new Error('RERUN_MARKER');
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
getMailConfig: () => ({
|
||||
provider: 'qq',
|
||||
label: 'QQ mail',
|
||||
source: 'mail-qq',
|
||||
url: 'https://mail.qq.com',
|
||||
navigateOnReuse: false,
|
||||
}),
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret', oauthUrl: 'https://oauth.example/latest' }),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
isVerificationMailPollingError: () => true,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
resolveVerificationStep: async () => {
|
||||
throw new Error('Content script on icloud-mail did not respond in 1s. Try refreshing the tab and retry.');
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
setState: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async () => {},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep8({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
}),
|
||||
/RERUN_MARKER/
|
||||
);
|
||||
|
||||
assert.equal(calls.rerunStep7, 1);
|
||||
assert.equal(calls.ensureReady >= 4, true);
|
||||
assert.equal(
|
||||
calls.logs.some(({ message }) => /连续重试 \d+ 次,改为回到步骤 7/.test(message)),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
@@ -51,4 +51,10 @@ assert.strictEqual(
|
||||
'真实业务错误不应被误判为可重试传输错误'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
api.isRetryableContentScriptTransportError(new Error('TypeError: Failed to fetch')),
|
||||
true,
|
||||
'Failed to fetch 应进入可重试传输错误分支'
|
||||
);
|
||||
|
||||
console.log('step8 state timeout retry tests passed');
|
||||
|
||||
@@ -601,6 +601,69 @@ test('verification flow caps mail polling timeout to the remaining oauth budget'
|
||||
assert.equal(mailPollCalls[0].payload.maxAttempts, 2);
|
||||
});
|
||||
|
||||
test('verification flow keeps mail polling response timeout above minimum floor', async () => {
|
||||
const mailPollCalls = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type === 'FILL_CODE') {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async (_mail, message, options) => {
|
||||
mailPollCalls.push({
|
||||
payload: message.payload,
|
||||
options,
|
||||
});
|
||||
return { code: '654321', emailTimestamp: 123 };
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await helpers.resolveVerificationStep(
|
||||
8,
|
||||
{
|
||||
email: 'user@example.com',
|
||||
lastLoginCode: null,
|
||||
},
|
||||
{ provider: 'qq', label: 'QQ 邮箱' },
|
||||
{
|
||||
getRemainingTimeMs: async () => 1200,
|
||||
resendIntervalMs: 0,
|
||||
}
|
||||
);
|
||||
|
||||
assert.ok(mailPollCalls.length >= 1);
|
||||
assert.equal(mailPollCalls[0].options.timeoutMs, 5000);
|
||||
assert.equal(mailPollCalls[0].options.responseTimeoutMs, 5000);
|
||||
});
|
||||
|
||||
test('verification flow keeps 2925 mailbox polling at 15 refresh attempts even when oauth budget is smaller', async () => {
|
||||
const mailPollCalls = [];
|
||||
|
||||
@@ -1155,7 +1218,7 @@ test('verification flow notifies onResendRequestedAt when resend is triggered',
|
||||
{ provider: 'qq', label: 'QQ 邮箱' },
|
||||
{
|
||||
maxResendRequests: 1,
|
||||
resendIntervalMs: 25000,
|
||||
resendIntervalMs: 0,
|
||||
onResendRequestedAt: async (requestedAt) => {
|
||||
resendRequestedAtCalls.push(Number(requestedAt) || 0);
|
||||
},
|
||||
@@ -1219,6 +1282,146 @@ test('verification flow uses resilient signup-page transport when submitting ver
|
||||
assert.ok(resilientCalls[0].options.timeoutMs >= 30000);
|
||||
});
|
||||
|
||||
test('verification flow forwards optional signup profile payload when submitting signup verification code', async () => {
|
||||
const resilientCalls = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not use non-resilient channel');
|
||||
},
|
||||
sendToContentScriptResilient: async (_source, message) => {
|
||||
resilientCalls.push(message);
|
||||
return { success: true, skipProfileStep: true };
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => ({}),
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
const result = await helpers.submitVerificationCode(4, '654321', {
|
||||
signupProfile: {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
year: 2003,
|
||||
month: 6,
|
||||
day: 19,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(result, { success: true, skipProfileStep: true });
|
||||
assert.deepStrictEqual(resilientCalls[0].payload.signupProfile, {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
year: 2003,
|
||||
month: 6,
|
||||
day: 19,
|
||||
});
|
||||
});
|
||||
|
||||
test('verification flow keeps combined signup profile skip reason when completing signup verification', async () => {
|
||||
const completed = [];
|
||||
const resilientCalls = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
completed.push({ step, payload });
|
||||
},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not use non-resilient channel');
|
||||
},
|
||||
sendToContentScriptResilient: async (_source, message) => {
|
||||
resilientCalls.push(message);
|
||||
return {
|
||||
success: true,
|
||||
skipProfileStep: true,
|
||||
skipProfileStepReason: 'combined_verification_profile',
|
||||
};
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => ({
|
||||
code: '654321',
|
||||
emailTimestamp: 123,
|
||||
}),
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await helpers.resolveVerificationStep(
|
||||
4,
|
||||
{ email: 'user@example.com', lastSignupCode: null },
|
||||
{ provider: 'qq', label: 'QQ 邮箱' },
|
||||
{
|
||||
signupProfile: {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
year: 2003,
|
||||
month: 6,
|
||||
day: 19,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(resilientCalls[0].payload.signupProfile.firstName, 'Ada');
|
||||
assert.deepStrictEqual(completed, [
|
||||
{
|
||||
step: 4,
|
||||
payload: {
|
||||
emailTimestamp: 123,
|
||||
code: '654321',
|
||||
phoneVerificationRequired: false,
|
||||
skipProfileStep: true,
|
||||
skipProfileStepReason: 'combined_verification_profile',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('verification flow treats retryable submit transport failure as success when step 4 already redirected to logged-in home', async () => {
|
||||
const logs = [];
|
||||
|
||||
@@ -1270,3 +1473,196 @@ test('verification flow treats retryable submit transport failure as success whe
|
||||
assert.equal(result.transportRecovered, true);
|
||||
assert.equal(logs.some(({ message }) => /验证码提交后页面已切换到ChatGPT 已登录首页/.test(message)), true);
|
||||
});
|
||||
|
||||
test('verification flow avoids resend storms when iCloud polling keeps hitting transport errors', async () => {
|
||||
let resendRequests = 0;
|
||||
let pollAttempts = 0;
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type === 'RESEND_VERIFICATION_CODE') {
|
||||
resendRequests += 1;
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
pollAttempts += 1;
|
||||
throw new Error('Content script on icloud-mail did not respond in 1s. Try refreshing the tab and retry.');
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
helpers.pollFreshVerificationCodeWithResendInterval(
|
||||
8,
|
||||
{ email: 'user@example.com', lastLoginCode: null },
|
||||
{ source: 'icloud-mail', provider: 'icloud', label: 'iCloud 邮箱' },
|
||||
{
|
||||
intervalMs: 1000,
|
||||
maxAttempts: 1,
|
||||
resendIntervalMs: 25000,
|
||||
maxResendRequests: 2,
|
||||
}
|
||||
),
|
||||
/页面通信异常连续/
|
||||
);
|
||||
|
||||
assert.equal(pollAttempts >= 6, true);
|
||||
assert.equal(resendRequests, 0);
|
||||
});
|
||||
|
||||
test('verification flow stops iCloud poll-only loop after repeated no-code rounds before resend', async () => {
|
||||
let resendRequests = 0;
|
||||
let pollCalls = 0;
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type === 'RESEND_VERIFICATION_CODE') {
|
||||
resendRequests += 1;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
pollCalls += 1;
|
||||
return {};
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
helpers.pollFreshVerificationCodeWithResendInterval(
|
||||
8,
|
||||
{ email: 'user@example.com', lastLoginCode: null },
|
||||
{ source: 'icloud-mail', provider: 'icloud', label: 'iCloud 邮箱' },
|
||||
{
|
||||
intervalMs: 1000,
|
||||
maxAttempts: 1,
|
||||
resendIntervalMs: 25000,
|
||||
maxResendRequests: 2,
|
||||
}
|
||||
),
|
||||
/空轮询循环|停止当前链路/
|
||||
);
|
||||
|
||||
assert.equal(pollCalls >= 4, true);
|
||||
assert.equal(resendRequests, 0);
|
||||
});
|
||||
|
||||
test('verification flow caps iCloud polling response timeout to avoid long silent stalls', async () => {
|
||||
const pollTimeouts = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type === 'RESEND_VERIFICATION_CODE') {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async (_mail, _message, options = {}) => {
|
||||
pollTimeouts.push({
|
||||
timeoutMs: Number(options.timeoutMs) || 0,
|
||||
responseTimeoutMs: Number(options.responseTimeoutMs) || 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
helpers.pollFreshVerificationCodeWithResendInterval(
|
||||
4,
|
||||
{ email: 'user@example.com', lastSignupCode: null },
|
||||
{ source: 'icloud-mail', provider: 'icloud', label: 'iCloud 邮箱' },
|
||||
{
|
||||
intervalMs: 3000,
|
||||
maxAttempts: 5,
|
||||
resendIntervalMs: 25000,
|
||||
maxResendRequests: 1,
|
||||
}
|
||||
),
|
||||
/空轮询循环|停止当前链路/
|
||||
);
|
||||
|
||||
assert.equal(pollTimeouts.length > 0, true);
|
||||
assert.equal(pollTimeouts.every(({ timeoutMs }) => timeoutMs > 0 && timeoutMs <= 22000), true);
|
||||
assert.equal(
|
||||
pollTimeouts.every(({ responseTimeoutMs }) => responseTimeoutMs > 0 && responseTimeoutMs <= 18000),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user