diff --git a/.gitignore b/.gitignore
index 9ed4eab3..38370bf0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
.omx/
/node_modules
/.runtime
+/.env
/docs/新步骤顺序
__pycache__/
*.pyc
diff --git a/background.js b/background.js
index 62bb91a2..a2ce5eaf 100644
--- a/background.js
+++ b/background.js
@@ -670,6 +670,7 @@ const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL;
+const DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL = 'http://127.0.0.1:17374';
const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000;
const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai';
const DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php';
@@ -696,6 +697,7 @@ const DEFAULT_NEX_SMS_BASE_URL = 'https://api.nexsms.net';
const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot';
const DEFAULT_NEX_SMS_COUNTRY_ORDER = Object.freeze([1]);
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high';
@@ -1422,6 +1424,7 @@ const PERSISTED_SETTING_DEFAULTS = {
heroSmsCountryId: HERO_SMS_COUNTRY_ID,
heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL,
heroSmsCountryFallback: [],
+ heroSmsOperator: DEFAULT_HERO_SMS_OPERATOR,
fiveSimApiKey: '',
fiveSimProduct: DEFAULT_FIVE_SIM_PRODUCT,
fiveSimCountryId: FIVE_SIM_COUNTRY_ID,
@@ -1801,6 +1804,21 @@ function normalizeHeroSmsCountryFallback(value = []) {
return normalized;
}
+function normalizeHeroSmsOperator(value = '', fallback = DEFAULT_HERO_SMS_OPERATOR) {
+ const normalized = String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '');
+ if (normalized) {
+ return normalized;
+ }
+ const fallbackNormalized = String(fallback || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '');
+ return fallbackNormalized || DEFAULT_HERO_SMS_OPERATOR;
+}
+
function normalizePhoneSmsProvider(value = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
@@ -2707,6 +2725,14 @@ async function markCurrentRegistrationAccountUsed(state = {}, options = {}) {
updated = Boolean(result?.updated) || updated;
}
+ if (typeof removeCurrentCustomMailProviderPoolEmail === 'function') {
+ const result = await removeCurrentCustomMailProviderPoolEmail(latestState, {
+ logPrefix: `${reasonPrefix}:自定义邮箱号池`,
+ level: options.level || 'warn',
+ });
+ updated = Boolean(result?.updated) || updated;
+ }
+
return { updated };
}
@@ -2726,6 +2752,46 @@ function getCustomMailProviderPoolEmailForRun(state = {}, targetRun = 1) {
return entries[numericRun - 1] || '';
}
+async function removeCurrentCustomMailProviderPoolEmail(state = {}, options = {}) {
+ if (String(state?.mailProvider || '').trim().toLowerCase() !== 'custom') {
+ return { updated: false };
+ }
+
+ const currentEmail = String(state?.email || '').trim().toLowerCase();
+ if (!currentEmail) {
+ return { updated: false };
+ }
+
+ const currentPool = getCustomMailProviderPool(state);
+ if (!currentPool.length || !currentPool.includes(currentEmail)) {
+ return { updated: false };
+ }
+
+ const nextPool = currentPool.filter((email) => email !== currentEmail);
+ const nextEmail = nextPool[0] || null;
+ await setPersistentSettings({ customMailProviderPool: nextPool });
+ await setEmailStateSilently(nextEmail, {
+ source: nextEmail ? 'custom-mail-provider-pool-next' : 'custom-mail-provider-pool-empty',
+ });
+ await setState({ customMailProviderPool: nextPool });
+ broadcastDataUpdate({
+ customMailProviderPool: nextPool,
+ email: nextEmail,
+ });
+ const logPrefix = String(options.logPrefix || '').trim() || '自定义邮箱号池:流程成功后';
+ await addLog(
+ nextEmail
+ ? `${logPrefix}已从号池删除 ${currentEmail},下轮将使用 ${nextEmail}。`
+ : `${logPrefix}已从号池删除 ${currentEmail},号池已为空。`,
+ options.level || 'ok'
+ );
+ return {
+ updated: true,
+ customMailProviderPool: nextPool,
+ email: nextEmail,
+ };
+}
+
function normalizePanelMode(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'sub2api') {
@@ -3526,6 +3592,8 @@ function normalizePersistentSettingValue(key, value) {
return String(value || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL;
case 'heroSmsCountryFallback':
return normalizeHeroSmsCountryFallback(value);
+ case 'heroSmsOperator':
+ return normalizeHeroSmsOperator(value, DEFAULT_HERO_SMS_OPERATOR);
case 'fiveSimApiKey':
return String(value || '');
case 'fiveSimProduct':
@@ -3900,6 +3968,15 @@ function mergeAutoRunKeepStateValue(baseValue, patchValue) {
function collectAutoRunFreshResetRuntimeSettingKeys() {
const keySet = new Set();
+ [
+ 'currentCompletionTokenByNode',
+ 'seenCodes',
+ 'seenInbucketMailIds',
+ 'signupVerificationRequestedAt',
+ 'loginVerificationRequestedAt',
+ 'oauthFlowDeadlineAt',
+ 'oauthFlowDeadlineSourceUrl',
+ ].forEach((field) => keySet.add(field));
const flowFieldGroups = isPlainObjectValue(runtimeStateHelpers?.FLOW_FIELD_GROUPS)
? runtimeStateHelpers.FLOW_FIELD_GROUPS
: {};
@@ -5271,6 +5348,106 @@ function buildHotmailLocalEndpoint(baseUrl, path) {
return new URL(path, `${normalizedBaseUrl}/`).toString();
}
+function buildCustomMailLocalEndpoint(path) {
+ return new URL(path, `${DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL}/`).toString();
+}
+
+async function requestCustomMailLocalCode(pollPayload = {}) {
+ const requestTimeoutMs = HOTMAIL_LOCAL_HELPER_TIMEOUT_MS;
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs);
+
+ let response;
+ try {
+ response = await fetch(buildCustomMailLocalEndpoint('/code'), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({
+ top: pollPayload.top || 20,
+ targetEmail: pollPayload.targetEmail || '',
+ senderFilters: pollPayload.senderFilters || [],
+ subjectFilters: pollPayload.subjectFilters || [],
+ requiredKeywords: pollPayload.requiredKeywords || [],
+ codePatterns: pollPayload.codePatterns || [],
+ excludeCodes: pollPayload.excludeCodes || [],
+ filterAfterTimestamp: Number(pollPayload.filterAfterTimestamp || 0) || 0,
+ }),
+ signal: controller.signal,
+ });
+ } catch (err) {
+ if (err?.name === 'AbortError') {
+ throw new Error(`自定义邮箱本地助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`);
+ }
+ throw new Error(`自定义邮箱本地助手请求失败:${err.message}`);
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ const text = await response.text();
+ let payload = {};
+ try {
+ payload = text ? JSON.parse(text) : {};
+ } catch {
+ payload = { raw: text };
+ }
+
+ if (!response.ok || payload?.ok === false) {
+ const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`;
+ throw new Error(`自定义邮箱本地助手返回失败:${errorText}`);
+ }
+
+ return {
+ code: String(payload?.code || ''),
+ message: payload?.message || null,
+ usedTimeFallback: Boolean(payload?.usedTimeFallback),
+ };
+}
+
+async function pollCustomMailVerificationCode(step, state, pollPayload = {}) {
+ const maxAttempts = Number(pollPayload.maxAttempts) || 5;
+ const intervalMs = Number(pollPayload.intervalMs) || 3000;
+ let lastError = null;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ throwIfStopped();
+ try {
+ await addLog(`步骤 ${step}:正在通过自定义邮箱本地助手轮询验证码(${attempt}/${maxAttempts})...`, 'info');
+ const fetchResult = await requestCustomMailLocalCode({
+ ...pollPayload,
+ targetEmail: pollPayload.targetEmail || state?.email || '',
+ });
+
+ if (fetchResult.code) {
+ if (fetchResult.usedTimeFallback) {
+ await addLog(`步骤 ${step}:自定义邮箱本地助手使用时间回退后命中验证码。`, 'warn');
+ }
+ await addLog(`步骤 ${step}:已通过自定义邮箱本地助手找到验证码:${fetchResult.code}`, 'ok');
+ return {
+ ok: true,
+ code: fetchResult.code,
+ emailTimestamp: fetchResult.message?.receivedTimestamp || Date.now(),
+ mailId: fetchResult.message?.id || '',
+ };
+ }
+
+ lastError = new Error(`步骤 ${step}:自定义邮箱本地助手暂未返回匹配验证码(${attempt}/${maxAttempts})。`);
+ await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info');
+ } catch (err) {
+ lastError = err;
+ await addLog(`步骤 ${step}:自定义邮箱本地助手轮询失败:${err.message}`, 'warn');
+ }
+
+ if (attempt < maxAttempts) {
+ await sleepWithStop(intervalMs);
+ }
+ }
+
+ throw lastError || new Error(`步骤 ${step}:自定义邮箱本地助手未返回新的匹配验证码。`);
+}
+
async function requestHotmailRemoteMailbox(account, mailbox = 'INBOX') {
if (!account?.email) {
throw new Error('Hotmail 账号缺少邮箱地址。');
@@ -9139,9 +9316,15 @@ async function setNodeStatus(nodeId, status) {
const state = await getState();
const nodeStatuses = { ...(state.nodeStatuses || {}) };
nodeStatuses[normalizedNodeId] = status;
+ const nextCurrentNodeId = isStepDoneStatus(status)
+ ? (getFirstUnfinishedNodeId(nodeStatuses, {
+ ...state,
+ nodeStatuses,
+ }) || normalizedNodeId)
+ : normalizedNodeId;
await setState({
nodeStatuses,
- currentNodeId: normalizedNodeId,
+ currentNodeId: nextCurrentNodeId,
});
chrome.runtime.sendMessage({
type: 'NODE_STATUS_CHANGED',
@@ -10870,6 +11053,7 @@ const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([
'fetch-bound-email-login-code',
'post-bound-email-phone-verification',
'confirm-oauth',
+ 'platform-verify',
'kiro-open-register-page',
'kiro-submit-email',
'kiro-submit-name',
@@ -10884,7 +11068,6 @@ const STEP_COMPLETION_SIGNAL_STEP_KEYS = new Set([
'fill-password',
'fill-profile',
'gopay-subscription-confirm',
- 'platform-verify',
]);
const STEP_COMPLETION_SIGNAL_TIMEOUTS_BY_STEP_KEY = new Map([
['fill-profile', 150000],
@@ -10999,10 +11182,24 @@ function getStepCompletionSignalTimeoutMs(step, state = {}) {
return getNodeCompletionSignalTimeoutMs(getNodeIdByStepForState(step, state), state);
}
+function createNodeCompletionToken(nodeId) {
+ const normalizedNodeId = String(nodeId || '').trim() || 'node';
+ const randomPart = Math.random().toString(36).slice(2, 10);
+ return `${normalizedNodeId}:${Date.now()}:${randomPart}`;
+}
+
function notifyNodeComplete(nodeId, payload) {
const normalizedNodeId = String(nodeId || '').trim();
const waiter = nodeWaiters.get(normalizedNodeId);
console.log(LOG_PREFIX, `[notifyNodeComplete] node ${normalizedNodeId}, hasWaiter=${Boolean(waiter)}`);
+ const expectedToken = String(waiter?.completionToken || '').trim();
+ if (waiter && expectedToken) {
+ const actualToken = String(payload?.completionToken || payload?.stepCompletionToken || '').trim();
+ if (actualToken !== expectedToken) {
+ console.warn(LOG_PREFIX, `[notifyNodeComplete] ignore stale completion for node ${normalizedNodeId}: expected token ${expectedToken}, got ${actualToken || 'empty'}`);
+ return;
+ }
+ }
if (waiter) waiter.resolve(payload);
}
@@ -11129,6 +11326,53 @@ async function finalizeDeferredStepExecutionError(step, error) {
return finalizeDeferredNodeExecutionError(nodeId, error);
}
+async function recoverFillProfileCompletionFromBackground(executeError = null, completionToken = '') {
+ const transportMessage = getErrorMessage(executeError);
+ await addLog('步骤 5:资料提交后页面通信中断,正在通过后台复核最终状态...', 'warn', {
+ step: 5,
+ stepKey: 'fill-profile',
+ });
+
+ const signupTabId = await getTabId('openai-auth');
+ if (!signupTabId) {
+ throw new Error('步骤 5:资料提交后页面通信中断,且缺少认证页标签页,无法通过后台复核最终状态。');
+ }
+
+ await waitForTabStableComplete(signupTabId, {
+ timeoutMs: 120000,
+ retryDelayMs: 300,
+ stableMs: 1000,
+ initialDelayMs: 800,
+ });
+
+ const completionPayload = {
+ nodeId: 'fill-profile',
+ step: 5,
+ ...(completionToken ? { completionToken } : {}),
+ outcome: 'background_transport_recovered',
+ navigationStarted: true,
+ requireContentStateBeforeUrlSuccess: true,
+ retryableTransportError: transportMessage,
+ };
+ const pageState = await validateStep5PostCompletion(signupTabId, completionPayload);
+ const recoveredPayload = {
+ ...completionPayload,
+ ...(pageState?.successState ? { successState: pageState.successState } : {}),
+ ...(pageState?.url ? { url: pageState.url } : {}),
+ ...(pageState?.postSubmitPromptActionsCompleted ? { postSubmitPromptActionsCompleted: true } : {}),
+ ...(Number(pageState?.postSubmitPromptActionCount) > 0 ? { postSubmitPromptActionCount: Number(pageState.postSubmitPromptActionCount) } : {}),
+ recoveredByBackground: true,
+ step5PostCompletionValidated: true,
+ };
+
+ await addLog('步骤 5 [调试] 资料页完成信号丢失,后台复核已确认成功。', 'ok', {
+ step: 5,
+ stepKey: 'fill-profile',
+ });
+ notifyNodeComplete('fill-profile', recoveredPayload);
+ return recoveredPayload;
+}
+
async function executeNodeViaCompletionSignal(nodeId, timeoutMs = 0) {
const normalizedNodeId = String(nodeId || '').trim();
const executionState = await getState();
@@ -11138,14 +11382,25 @@ async function executeNodeViaCompletionSignal(nodeId, timeoutMs = 0) {
const resolvedTimeoutMs = Number(timeoutMs) > 0
? timeoutMs
: getNodeCompletionSignalTimeoutMs(normalizedNodeId, executionState);
+ const completionToken = createNodeCompletionToken(normalizedNodeId);
+ await setState({
+ currentCompletionTokenByNode: {
+ ...(executionState.currentCompletionTokenByNode || {}),
+ [normalizedNodeId]: completionToken,
+ },
+ });
const completionResultPromise = waitForNodeComplete(normalizedNodeId, resolvedTimeoutMs).then(
payload => ({ ok: true, payload }),
error => ({ ok: false, error }),
);
+ const waiter = nodeWaiters.get(normalizedNodeId);
+ if (waiter) {
+ waiter.completionToken = completionToken;
+ }
let executeError = null;
try {
- await executeNode(normalizedNodeId, { deferRetryableTransportError: true });
+ await executeNode(normalizedNodeId, { deferRetryableTransportError: true, completionToken });
} catch (err) {
executeError = err;
if (isStopError(err) || !isRetryableContentScriptTransportError(err)) {
@@ -11153,6 +11408,60 @@ async function executeNodeViaCompletionSignal(nodeId, timeoutMs = 0) {
}
}
+ const shouldTryStep5BackgroundRecovery = (
+ normalizedNodeId === 'fill-profile'
+ && executeError
+ && isRetryableContentScriptTransportError(executeError)
+ );
+ if (shouldTryStep5BackgroundRecovery) {
+ const taggedCompletionPromise = completionResultPromise.then((result) => ({
+ source: 'completion',
+ ...result,
+ }));
+ const taggedBackgroundRecoveryPromise = recoverFillProfileCompletionFromBackground(executeError, completionToken).then(
+ (payload) => ({
+ source: 'background-recovery',
+ ok: true,
+ payload,
+ }),
+ (error) => ({
+ source: 'background-recovery',
+ ok: false,
+ error,
+ }),
+ );
+
+ const firstResult = await Promise.race([
+ taggedCompletionPromise,
+ taggedBackgroundRecoveryPromise,
+ ]);
+
+ if (firstResult.ok) {
+ console.warn(
+ LOG_PREFIX,
+ `[executeNodeViaCompletionSignal] node ${normalizedNodeId} completed after deferred execute error: ${getErrorMessage(executeError)}`
+ );
+ return firstResult.payload;
+ }
+
+ const alternateResult = firstResult.source === 'completion'
+ ? await taggedBackgroundRecoveryPromise
+ : await taggedCompletionPromise;
+ if (alternateResult.ok) {
+ console.warn(
+ LOG_PREFIX,
+ `[executeNodeViaCompletionSignal] node ${normalizedNodeId} completed after deferred execute error: ${getErrorMessage(executeError)}`
+ );
+ return alternateResult.payload;
+ }
+
+ const recoveryError = firstResult.source === 'background-recovery'
+ ? firstResult.error
+ : alternateResult.error;
+ await finalizeDeferredNodeExecutionError(normalizedNodeId, recoveryError || executeError);
+ throw recoveryError || executeError;
+ }
+
const completionResult = await completionResultPromise;
if (completionResult.ok) {
if (executeError) {
@@ -11520,7 +11829,7 @@ const STEP_FETCH_NETWORK_RETRY_POLICIES = new Map([
]);
async function executeNode(nodeId, options = {}) {
- const { deferRetryableTransportError = false } = options;
+ const { deferRetryableTransportError = false, completionToken = '' } = options;
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) {
throw new Error('executeNode 缺少 nodeId。');
@@ -11574,6 +11883,7 @@ async function executeNode(nodeId, options = {}) {
...state,
visibleStep: Number(step),
nodeId: normalizedNodeId,
+ ...(completionToken ? { completionToken } : {}),
nodeDefinition: getNodeDefinitionForState(normalizedNodeId, state),
stepDefinition: getStepDefinitionForState(step, state),
});
@@ -11693,15 +12003,22 @@ async function executeNodeAndWait(nodeId, delayAfter = 2000) {
if (normalizedNodeId === 'fill-profile') {
const signupTabId = await getTabId('openai-auth');
if (signupTabId) {
- await addLog('自动运行:填写资料节点已收到完成信号,正在等待当前页面完成加载并稳定...', 'info');
- await waitForTabStableComplete(signupTabId, {
- timeoutMs: 120000,
- retryDelayMs: 300,
- stableMs: 1000,
- initialDelayMs: 800,
- });
try {
- await validateStep5PostCompletion(signupTabId, completionPayload || {});
+ if (!completionPayload?.step5PostCompletionValidated) {
+ await addLog('自动运行:填写资料节点已收到完成信号,正在等待当前页面完成加载并稳定...', 'info');
+ await waitForTabStableComplete(signupTabId, {
+ timeoutMs: 120000,
+ retryDelayMs: 300,
+ stableMs: 1000,
+ initialDelayMs: 800,
+ });
+ await validateStep5PostCompletion(signupTabId, completionPayload || {});
+ } else {
+ await addLog('自动运行:步骤 5 后台恢复已完成最终复核,直接进入后续节点。', 'ok', {
+ step: 5,
+ stepKey: 'fill-profile',
+ });
+ }
await setNodeStatus(normalizedNodeId, 'completed');
await addLog('已完成', 'ok', { nodeId: normalizedNodeId });
await addLog('步骤 5 [调试] 资料页完成信号已通过后台复核。', 'ok', {
@@ -12525,10 +12842,6 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) {
return null;
}
- if (currentState.email) {
- return currentState.email;
- }
-
if (isCustomMailProvider(currentState)) {
const poolSize = getCustomMailProviderPool(currentState).length;
if (poolSize > 0) {
@@ -12537,11 +12850,15 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) {
throw new Error(`自定义邮箱号池第 ${targetRun} 个邮箱不存在,请检查号池数量是否与自动轮数一致。`);
}
await setEmailState(queuedEmail);
- await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;第 4/8 步仍需手动输入验证码)===`, 'ok');
+ await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;请确保自定义邮箱本地助手已启动)===`, 'ok');
return queuedEmail;
}
}
+ if (currentState.email) {
+ return currentState.email;
+ }
+
if (isCustomEmailPoolGenerator(currentState)) {
const queuedEmail = getCustomEmailPoolEmailForRun(currentState, targetRun);
if (!queuedEmail) {
@@ -12695,7 +13012,7 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) {
throw new Error(`自定义邮箱号池第 ${targetRun} 个邮箱不存在,请检查号池数量是否与自动轮数一致。`);
}
await setEmailState(queuedEmail);
- await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;第 4/8 步仍需手动输入验证码)===`, 'ok');
+ await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;请确保自定义邮箱本地助手已启动)===`, 'ok');
return queuedEmail;
}
}
@@ -13462,6 +13779,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create
closeConflictingTabsForSource,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
CLOUD_MAIL_PROVIDER,
+ CUSTOM_MAIL_PROVIDER: 'custom',
completeNodeFromBackground,
confirmCustomVerificationStepBypassRequest: (step) => chrome.runtime.sendMessage({
type: 'REQUEST_CUSTOM_VERIFICATION_BYPASS_CONFIRMATION',
@@ -13483,6 +13801,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create
MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
pollCloudflareTempEmailVerificationCode,
pollCloudMailVerificationCode,
+ pollCustomMailVerificationCode,
pollHotmailVerificationCode,
pollLuckmailVerificationCode,
pollYydsMailVerificationCode,
@@ -13630,6 +13949,7 @@ const step5Executor = self.MultiPageBackgroundStep5?.createStep5Executor({
addLog,
generateRandomBirthday,
generateRandomName,
+ getState,
sendToContentScript,
});
const step6Executor = self.MultiPageBackgroundStep6?.createStep6Executor({
@@ -14083,13 +14403,20 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
if (!signupTabId) {
throw new Error('步骤 5:缺少认证页标签页,无法确认是否已跳转到 https://chatgpt.com。');
}
- await waitForTabStableComplete(signupTabId, {
- timeoutMs: 120000,
- retryDelayMs: 300,
- stableMs: 1000,
- initialDelayMs: 800,
- });
- await validateStep5PostCompletion(signupTabId, completionPayload || {});
+ if (!completionPayload?.step5PostCompletionValidated) {
+ await waitForTabStableComplete(signupTabId, {
+ timeoutMs: 120000,
+ retryDelayMs: 300,
+ stableMs: 1000,
+ initialDelayMs: 800,
+ });
+ await validateStep5PostCompletion(signupTabId, completionPayload || {});
+ } else {
+ await addLog('步骤 5:后台已完成最终状态复核,直接标记完成并进入下一步。', 'ok', {
+ step: 5,
+ stepKey: 'fill-profile',
+ });
+ }
await setNodeStatus('fill-profile', 'completed');
await addLog('已完成', 'ok', { nodeId: 'fill-profile' });
},
@@ -14131,6 +14458,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
listIcloudAliases,
listLuckmailPurchasesForManagement,
markCurrentCustomEmailPoolEntryUsed,
+ removeCurrentCustomMailProviderPoolEmail,
markCurrentRegistrationAccountUsed,
getCurrentMail2925Account,
normalizeHotmailAccounts,
@@ -14876,6 +15204,119 @@ async function getStep5SubmitStateFromContent(options = {}) {
return result || {};
}
+async function advanceStep5PostSubmitPromptOnTab(options = {}) {
+ try {
+ const result = await sendToContentScriptResilient(
+ 'openai-auth',
+ {
+ type: 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ source: 'background',
+ payload: {},
+ },
+ {
+ timeoutMs: options.timeoutMs ?? 15000,
+ retryDelayMs: options.retryDelayMs ?? 600,
+ responseTimeoutMs: options.responseTimeoutMs ?? (options.timeoutMs ?? 15000),
+ logMessage: options.logMessage || '步骤 5:正在检查注册完成后的弹窗按钮...',
+ logStep: 5,
+ logStepKey: options.logStepKey || 'fill-profile',
+ }
+ );
+
+ if (result?.error) {
+ throw new Error(result.error);
+ }
+
+ if (result?.advanced) {
+ return result;
+ }
+ } catch (error) {
+ await addLog(`步骤 5:页面脚本处理注册后弹窗未返回,改用后台兜底点击。${getErrorMessage(error)}`, 'info', {
+ step: 5,
+ stepKey: options.logStepKey || 'fill-profile',
+ });
+ }
+
+ const tabId = Number.isInteger(options.tabId)
+ ? options.tabId
+ : await getTabId('openai-auth').catch(() => null);
+ if (!Number.isInteger(tabId)) {
+ return { advanced: false };
+ }
+
+ try {
+ const executionResults = await chrome.scripting.executeScript({
+ target: { tabId },
+ world: 'ISOLATED',
+ func: () => {
+ const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
+ const isVisible = (el) => {
+ if (!el || el.hidden) return false;
+ const rect = typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
+ if (rect && rect.width <= 0 && rect.height <= 0) return false;
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null;
+ return !(style && (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0'));
+ };
+ const isEnabled = (el) => Boolean(el)
+ && !el.disabled
+ && el.getAttribute?.('aria-disabled') !== 'true'
+ && el.getAttribute?.('data-disabled') !== 'true';
+ const getText = (el) => normalize([
+ el?.innerText,
+ el?.textContent,
+ el?.value,
+ el?.getAttribute?.('aria-label'),
+ el?.getAttribute?.('title'),
+ el?.getAttribute?.('data-testid'),
+ el?.getAttribute?.('data-dd-action-name'),
+ ].filter(Boolean).join(' '));
+ const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]'))
+ .filter((el) => isVisible(el) && isEnabled(el))
+ .map((el) => ({ el, text: getText(el) }))
+ .filter(({ text }) => text && !/(?:完成\s*(?:帐户|账户|账号)?\s*创建|创建\s*(?:帐户|账户|账号)|create\s+(?:an\s+)?account|create\s+your\s+account|complete\s+(?:account\s+)?creation)/i.test(text));
+
+ if (candidates.length === 0) {
+ return { advanced: false, fallback: true, reason: 'prompt_not_detected' };
+ }
+
+ const pageText = normalize(document.body?.innerText || document.documentElement?.innerText || '');
+ const looksLikePostSubmitPrompt = /(?:是什么促使你使用\s*chatgpt|我们会利用这些信息|學校|学校|工作|个人任务|乐趣和娱乐|其他|你已准备就绪|可能会犯错|继续操作即表示你同意|条款|隐私政策|跳过|下一步|继续|同意|what\s+brings\s+you\s+to\s+chatgpt|you(?:'|’)re\s+all\s+set|continue|skip|agree)/i.test(pageText);
+ if (!looksLikePostSubmitPrompt) {
+ return { advanced: false, fallback: true, reason: 'prompt_not_detected', buttons: candidates.map(({ text }) => text).slice(0, 20) };
+ }
+
+ const exactSkip = candidates.find(({ text }) => /^(?:跳过|稍后|以后|skip|not\s+now|maybe\s+later)$/i.test(text));
+ const looseSkip = candidates.find(({ text }) => /(?:跳过|稍后|以后|skip|not\s+now|maybe\s+later)/i.test(text));
+ const exactContinue = candidates.find(({ text }) => /^(?:继续|下一步|同意|确认|知道了|我知道了|好的|continue|next|agree|accept|confirm|got\s+it|ok|okay|done|finish)$/i.test(text));
+ const looseContinue = candidates.find(({ text }) => /(?:继续|下一步|同意|确认|知道了|我知道了|好的|continue|next|agree|accept|confirm|got\s+it|ok|okay|done|finish)/i.test(text));
+ const target = exactSkip || looseSkip || exactContinue || looseContinue;
+ if (!target?.el) {
+ return { advanced: false, fallback: true, reason: 'button_not_found', buttons: candidates.map(({ text }) => text).slice(0, 20), pageText: pageText.slice(0, 300) };
+ }
+
+ target.el.scrollIntoView?.({ behavior: 'instant', block: 'center', inline: 'center' });
+ target.el.focus?.();
+ target.el.click?.();
+ return { advanced: true, actionText: target.text, fallback: true };
+ },
+ });
+ const fallbackResult = executionResults?.[0]?.result || { advanced: false };
+ if (fallbackResult?.advanced) {
+ await addLog(`步骤 5:后台兜底已点击注册后弹窗按钮“${fallbackResult.actionText || '跳过/继续'}”。`, 'info', {
+ step: 5,
+ stepKey: options.logStepKey || 'fill-profile',
+ });
+ }
+ return fallbackResult || { advanced: false };
+ } catch (error) {
+ await addLog(`步骤 5:后台兜底点击注册后弹窗失败:${getErrorMessage(error)}`, 'warn', {
+ step: 5,
+ stepKey: options.logStepKey || 'fill-profile',
+ });
+ return { advanced: false, error: getErrorMessage(error) };
+ }
+}
+
async function recoverStep5SubmitRetryPageOnTab(options = {}) {
const result = await sendToContentScriptResilient(
'openai-auth',
@@ -14949,7 +15390,11 @@ async function validateStep5PostCompletion(tabId, completionPayload = {}) {
};
const maxAuthRetryRecoveries = Math.max(1, Number(completionPayload?.maxAuthRetryRecoveries) || 2);
+ const maxPostSubmitPromptActions = Math.max(0, Number(completionPayload?.maxPostSubmitPromptActions) || 3);
+ const minPostSubmitPromptActions = Math.min(maxPostSubmitPromptActions, Math.max(0, Number(completionPayload?.minPostSubmitPromptActions) || 2));
let authRetryRecoveryCount = 0;
+ let postSubmitPromptActionCount = 0;
+ let lastPromptResult = null;
await debugLog('后台已收到资料页完成信号,准备开始最终状态复核。', {
completionOutcome: String(completionPayload?.outcome || '').trim(),
completionUrl: String(completionPayload?.url || '').trim(),
@@ -14959,8 +15404,81 @@ async function validateStep5PostCompletion(tabId, completionPayload = {}) {
while (true) {
const tab = await chrome.tabs.get(tabId).catch(() => null);
const currentUrl = String(tab?.url || completionPayload?.url || '').trim();
- if (currentUrl && isStep5CompletionChatgptUrl(currentUrl)) {
- await debugLog('后台直接通过标签页 URL 确认已进入 chatgpt.com,步骤 5 完成。', {
+
+ if (postSubmitPromptActionCount < maxPostSubmitPromptActions) {
+ const promptResult = await advanceStep5PostSubmitPromptOnTab({
+ tabId,
+ timeoutMs: 15000,
+ responseTimeoutMs: 15000,
+ retryDelayMs: 500,
+ logMessage: '步骤 5:正在处理注册完成后的跳过/继续弹窗...',
+ });
+ lastPromptResult = promptResult || null;
+ if (promptResult?.advanced) {
+ postSubmitPromptActionCount += 1;
+ await debugLog(`后台复核已处理注册后弹窗(${postSubmitPromptActionCount}/${maxPostSubmitPromptActions})。`, {
+ completionOutcome: String(completionPayload?.outcome || '').trim(),
+ completionUrl: String(completionPayload?.url || '').trim(),
+ navigationStarted: Boolean(completionPayload?.navigationStarted),
+ tabUrl: currentUrl,
+ pageState: promptResult?.state,
+ level: 'info',
+ });
+ await waitForTabStableComplete(tabId, {
+ timeoutMs: 10000,
+ retryDelayMs: 250,
+ stableMs: 500,
+ initialDelayMs: 300,
+ }).catch(() => null);
+ const latestTab = await chrome.tabs.get(tabId).catch(() => null);
+ const latestUrl = String(latestTab?.url || currentUrl || completionPayload?.url || '').trim();
+ if (postSubmitPromptActionCount < maxPostSubmitPromptActions) {
+ continue;
+ }
+ await debugLog(`后台复核已处理注册后弹窗 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,按已完成账号注册进入步骤 6。`, {
+ completionOutcome: String(completionPayload?.outcome || '').trim(),
+ completionUrl: String(completionPayload?.url || '').trim(),
+ navigationStarted: Boolean(completionPayload?.navigationStarted),
+ tabUrl: latestUrl,
+ pageState: promptResult?.state,
+ level: 'ok',
+ });
+ return {
+ successState: isStep5CompletionChatgptUrl(latestUrl) ? 'logged_in_home' : 'post_submit_prompts_completed',
+ url: latestUrl,
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount,
+ };
+ }
+ if (postSubmitPromptActionCount >= minPostSubmitPromptActions) {
+ await debugLog(`后台复核已处理注册后弹窗 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,最后一轮未检测到新弹窗,忽略等待并进入步骤 6。`, {
+ completionOutcome: String(completionPayload?.outcome || '').trim(),
+ completionUrl: String(completionPayload?.url || '').trim(),
+ navigationStarted: Boolean(completionPayload?.navigationStarted),
+ tabUrl: currentUrl,
+ pageState: lastPromptResult,
+ level: 'ok',
+ });
+ return {
+ successState: isStep5CompletionChatgptUrl(currentUrl) ? 'logged_in_home' : 'post_submit_prompts_completed',
+ url: currentUrl,
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount,
+ };
+ }
+ }
+
+ if (!completionPayload?.requireContentStateBeforeUrlSuccess && currentUrl && isStep5CompletionChatgptUrl(currentUrl)) {
+ if (postSubmitPromptActionCount > 0 && postSubmitPromptActionCount < maxPostSubmitPromptActions) {
+ await debugLog(`后台已进入 chatgpt.com,但注册后弹窗仅处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,继续等待下一轮弹窗处理。`, {
+ completionOutcome: String(completionPayload?.outcome || '').trim(),
+ completionUrl: String(completionPayload?.url || '').trim(),
+ navigationStarted: Boolean(completionPayload?.navigationStarted),
+ tabUrl: currentUrl,
+ level: 'info',
+ });
+ } else {
+ await debugLog('后台确认已进入 chatgpt.com 且没有待处理的注册后弹窗,步骤 5 完成。', {
completionOutcome: String(completionPayload?.outcome || '').trim(),
completionUrl: String(completionPayload?.url || '').trim(),
navigationStarted: Boolean(completionPayload?.navigationStarted),
@@ -14971,6 +15489,41 @@ async function validateStep5PostCompletion(tabId, completionPayload = {}) {
successState: 'logged_in_home',
url: currentUrl,
};
+ }
+ }
+
+ if (
+ completionPayload?.requireContentStateBeforeUrlSuccess
+ && currentUrl
+ && isStep5CompletionChatgptUrl(currentUrl)
+ && postSubmitPromptActionCount > 0
+ && lastPromptResult?.fallback
+ && lastPromptResult?.reason === 'prompt_not_detected'
+ ) {
+ if (postSubmitPromptActionCount < maxPostSubmitPromptActions) {
+ await debugLog(`后台兜底已进入 chatgpt.com,但注册后弹窗仅处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,继续等待下一轮弹窗处理。`, {
+ completionOutcome: String(completionPayload?.outcome || '').trim(),
+ completionUrl: String(completionPayload?.url || '').trim(),
+ navigationStarted: Boolean(completionPayload?.navigationStarted),
+ tabUrl: currentUrl,
+ pageState: lastPromptResult,
+ level: 'warn',
+ });
+ } else {
+ await debugLog('后台兜底确认注册后弹窗已处理完,当前已进入 chatgpt.com 正常首页,步骤 5 完成。', {
+ completionOutcome: String(completionPayload?.outcome || '').trim(),
+ completionUrl: String(completionPayload?.url || '').trim(),
+ navigationStarted: Boolean(completionPayload?.navigationStarted),
+ tabUrl: currentUrl,
+ pageState: lastPromptResult,
+ level: 'ok',
+ });
+ return {
+ successState: 'logged_in_home',
+ url: currentUrl,
+ recoveredByFallback: true,
+ };
+ }
}
const pageState = await getStep5SubmitStateFromContent({
@@ -15026,6 +15579,17 @@ async function validateStep5PostCompletion(tabId, completionPayload = {}) {
}
if (pageState.successState === 'logged_in_home' && isStep5CompletionChatgptUrl(pageState.url)) {
+ if (postSubmitPromptActionCount > 0 && postSubmitPromptActionCount < maxPostSubmitPromptActions) {
+ await debugLog(`后台复核确认已进入 chatgpt.com,但注册后弹窗仅处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,继续等待下一轮弹窗处理。`, {
+ completionOutcome: String(completionPayload?.outcome || '').trim(),
+ completionUrl: String(completionPayload?.url || '').trim(),
+ navigationStarted: Boolean(completionPayload?.navigationStarted),
+ tabUrl: currentUrl,
+ pageState,
+ level: 'info',
+ });
+ continue;
+ }
await debugLog(`后台复核确认成功状态:${pageState.successState}`, {
completionOutcome: String(completionPayload?.outcome || '').trim(),
completionUrl: String(completionPayload?.url || '').trim(),
diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js
index 82f0ed8c..aec60809 100644
--- a/background/auto-run-controller.js
+++ b/background/auto-run-controller.js
@@ -634,8 +634,17 @@
attemptRun,
sessionId,
}),
+ runId: `${sessionId}:${targetRun}:${attemptRun}`,
+ activeRunId: `${sessionId}:${targetRun}:${attemptRun}`,
currentNodeId: '',
nodeStatuses: buildFreshAttemptNodeStatuses(prevState),
+ currentCompletionTokenByNode: {},
+ seenCodes: [],
+ seenInbucketMailIds: [],
+ signupVerificationRequestedAt: null,
+ loginVerificationRequestedAt: null,
+ oauthFlowDeadlineAt: null,
+ oauthFlowDeadlineSourceUrl: null,
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
autoRunSessionId: sessionId,
tabRegistry: {},
diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js
index 08141efd..01f776b5 100644
--- a/background/phone-verification-flow.js
+++ b/background/phone-verification-flow.js
@@ -23,6 +23,7 @@
DEFAULT_FIVE_SIM_BASE_URL = 'https://5sim.net/v1',
DEFAULT_FIVE_SIM_PRODUCT = 'openai',
DEFAULT_FIVE_SIM_OPERATOR = 'any',
+ DEFAULT_HERO_SMS_OPERATOR = 'any',
DEFAULT_FIVE_SIM_COUNTRY_ORDER = ['thailand'],
DEFAULT_NEX_SMS_BASE_URL = 'https://api.nexsms.net',
DEFAULT_NEX_SMS_COUNTRY_ORDER = [1],
@@ -548,6 +549,21 @@
return String(value || '').trim() || fallback;
}
+ function normalizeHeroSmsOperator(value = '', fallback = DEFAULT_HERO_SMS_OPERATOR) {
+ const normalized = String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '');
+ if (normalized) {
+ return normalized;
+ }
+ const fallbackNormalized = String(fallback || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '');
+ return fallbackNormalized || DEFAULT_HERO_SMS_OPERATOR;
+ }
+
function inferHeroSmsCountryFromPhoneNumber(phoneNumber = '') {
const digits = String(phoneNumber || '').replace(/\D+/g, '');
if (!digits) {
@@ -2100,6 +2116,7 @@
provider,
apiKey,
baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL),
+ operator: normalizeHeroSmsOperator(state.heroSmsOperator, DEFAULT_HERO_SMS_OPERATOR),
countryCandidates: resolveCountryCandidates(state),
};
}
@@ -2113,10 +2130,33 @@
provider: PHONE_SMS_PROVIDER_HERO,
apiKey,
baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL),
+ operator: normalizeHeroSmsOperator(state.heroSmsOperator, DEFAULT_HERO_SMS_OPERATOR),
countryCandidates: resolveCountryCandidates(state),
};
}
+ async function mergeLatestPhoneSettingsState(state = {}) {
+ if (typeof getState !== 'function') {
+ return state || {};
+ }
+ try {
+ const latestState = await getState();
+ if (!latestState || typeof latestState !== 'object') {
+ return state || {};
+ }
+ return {
+ ...latestState,
+ ...(state || {}),
+ heroSmsOperator: normalizeHeroSmsOperator(
+ state?.heroSmsOperator,
+ latestState?.heroSmsOperator || DEFAULT_HERO_SMS_OPERATOR
+ ),
+ };
+ } catch (_) {
+ return state || {};
+ }
+ }
+
function parseActivationPayload(payload, fallback = null) {
const normalizedFallback = normalizeActivation(fallback) || normalizeActivationFallback(fallback);
const directActivation = normalizeActivation(payload);
@@ -2537,6 +2577,10 @@
service: HERO_SMS_SERVICE_CODE,
country: countryConfig.id,
};
+ const operator = normalizeHeroSmsOperator(config?.operator, DEFAULT_HERO_SMS_OPERATOR);
+ if (operator && operator !== DEFAULT_HERO_SMS_OPERATOR) {
+ query.operator = operator;
+ }
if (options.maxPrice !== null && options.maxPrice !== undefined) {
query.maxPrice = options.maxPrice;
if (options.fixedPrice !== false) {
@@ -3584,6 +3628,7 @@
}
async function requestPhoneActivation(state = {}, options = {}) {
+ state = await mergeLatestPhoneSettingsState(state);
if (normalizePhoneSmsProvider(state?.phoneSmsProvider) === PHONE_SMS_PROVIDER_FIVE_SIM) {
const provider = getFiveSimProviderForState(state);
if (provider) {
@@ -5101,6 +5146,7 @@
}
async function acquirePhoneActivation(state = {}, options = {}) {
+ state = await mergeLatestPhoneSettingsState(state);
const provider = normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER);
const providerOrder = resolvePhoneProviderOrder(state, provider);
const countryCandidates = resolveCountryCandidatesForProvider(state, provider);
diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js
index 75d20705..8d783e1d 100644
--- a/background/signup-flow-helpers.js
+++ b/background/signup-flow-helpers.js
@@ -60,6 +60,7 @@
const tabId = await reuseOrCreateTab('openai-auth', SIGNUP_ENTRY_URL, {
inject: OPENAI_AUTH_INJECT_FILES,
injectSource: 'openai-auth',
+ forceNew: Number(step) === 1,
});
await waitForSignupEntryTabToSettle(tabId, step);
diff --git a/background/verification-flow.js b/background/verification-flow.js
index c417724c..137adb35 100644
--- a/background/verification-flow.js
+++ b/background/verification-flow.js
@@ -12,6 +12,7 @@
closeConflictingTabsForSource,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
CLOUD_MAIL_PROVIDER = 'cloudmail',
+ CUSTOM_MAIL_PROVIDER = 'custom',
completeNodeFromBackground,
confirmCustomVerificationStepBypassRequest,
getNodeIdByStepForState,
@@ -29,6 +30,7 @@
MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
pollCloudflareTempEmailVerificationCode,
pollCloudMailVerificationCode,
+ pollCustomMailVerificationCode,
pollHotmailVerificationCode,
pollLuckmailVerificationCode,
pollYydsMailVerificationCode,
@@ -987,6 +989,13 @@
}, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`);
return pollCloudMailVerificationCode(step, state, timedPoll.payload);
}
+ if (mail.provider === CUSTOM_MAIL_PROVIDER && typeof pollCustomMailVerificationCode === 'function') {
+ const timedPoll = await applyMailPollingTimeBudget(step, {
+ ...getVerificationPollPayload(step, state),
+ ...cleanPollOverrides,
+ }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`);
+ return pollCustomMailVerificationCode(step, state, timedPoll.payload);
+ }
if (mail.provider === YYDS_MAIL_PROVIDER) {
const timedPoll = await applyMailPollingTimeBudget(step, {
...getVerificationPollPayload(step, state),
diff --git a/flows/openai/background/steps/fetch-login-code.js b/flows/openai/background/steps/fetch-login-code.js
index 1fc50829..acb5a82e 100644
--- a/flows/openai/background/steps/fetch-login-code.js
+++ b/flows/openai/background/steps/fetch-login-code.js
@@ -594,14 +594,6 @@
await addLog(`步骤 ${visibleStep}:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info');
}
- if (shouldUseCustomRegistrationEmail(preparedState)) {
- await confirmCustomVerificationStepBypass(8, {
- completionStep: visibleStep,
- promptStep: visibleStep,
- });
- return { lastResendAt: latestResendAt };
- }
-
if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') {
await addLog(`步骤 ${visibleStep}:正在确认 iCloud 邮箱登录态...`, 'info');
await ensureIcloudMailSession({
@@ -611,12 +603,21 @@
});
}
+ if (shouldUseCustomRegistrationEmail(preparedState) && mail.provider !== 'custom') {
+ await confirmCustomVerificationStepBypass(8, {
+ completionStep: visibleStep,
+ promptStep: visibleStep,
+ });
+ return { lastResendAt: latestResendAt };
+ }
+
throwIfStopped();
if (
mail.provider === HOTMAIL_PROVIDER
|| mail.provider === LUCKMAIL_PROVIDER
|| mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER
|| mail.provider === CLOUD_MAIL_PROVIDER
+ || mail.provider === 'custom'
) {
await addLog(`步骤 ${visibleStep}:正在通过 ${mail.label} 轮询验证码...`);
} else {
diff --git a/flows/openai/background/steps/fetch-signup-code.js b/flows/openai/background/steps/fetch-signup-code.js
index 4701b8d7..35dd06d4 100644
--- a/flows/openai/background/steps/fetch-signup-code.js
+++ b/flows/openai/background/steps/fetch-signup-code.js
@@ -93,14 +93,14 @@
}
async function executeSignupEmailVerificationStep(state, stepStartedAt, verificationSessionKey) {
- if (shouldUseCustomRegistrationEmail(state)) {
+ const mail = getMailConfig(state);
+ if (mail.error) throw new Error(mail.error);
+
+ if (shouldUseCustomRegistrationEmail(state) && mail.provider !== 'custom') {
await confirmCustomVerificationStepBypass(4);
return;
}
- const mail = getMailConfig(state);
- if (mail.error) throw new Error(mail.error);
-
const verificationFilterAfterTimestamp = mail.provider === '2925'
? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS)
: stepStartedAt;
@@ -120,6 +120,7 @@
|| mail.provider === LUCKMAIL_PROVIDER
|| mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER
|| mail.provider === CLOUD_MAIL_PROVIDER
+ || mail.provider === 'custom'
) {
await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`);
} else if (mail.provider === '2925') {
@@ -146,6 +147,7 @@
LUCKMAIL_PROVIDER,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
CLOUD_MAIL_PROVIDER,
+ 'custom',
].includes(mail.provider);
const signupProfile = buildSignupProfileForVerificationStep();
diff --git a/flows/openai/background/steps/fill-profile.js b/flows/openai/background/steps/fill-profile.js
index 9bc18886..dd7deeaa 100644
--- a/flows/openai/background/steps/fill-profile.js
+++ b/flows/openai/background/steps/fill-profile.js
@@ -6,12 +6,17 @@
addLog,
generateRandomBirthday,
generateRandomName,
+ getState,
sendToContentScript,
} = deps;
- async function executeStep5() {
+ async function executeStep5(state = {}) {
const { firstName, lastName } = generateRandomName();
const { year, month, day } = generateRandomBirthday();
+ const currentState = state && typeof state === 'object' && Object.keys(state).length
+ ? state
+ : (typeof getState === 'function' ? await getState() : {});
+ const completionToken = String(currentState?.completionToken || currentState?.currentCompletionTokenByNode?.['fill-profile'] || '').trim();
await addLog(`步骤 5:已生成姓名 ${firstName} ${lastName},生日 ${year}-${month}-${day}`);
@@ -26,6 +31,7 @@
year,
month,
day,
+ ...(completionToken ? { completionToken } : {}),
},
});
}
diff --git a/flows/openai/content/openai-auth.js b/flows/openai/content/openai-auth.js
index 2658fd1d..60f09e02 100644
--- a/flows/openai/content/openai-auth.js
+++ b/flows/openai/content/openai-auth.js
@@ -28,6 +28,7 @@ if (document.documentElement.getAttribute(OPENAI_AUTH_LISTENER_SENTINEL) !== '1'
|| message.type === 'GET_LOGIN_AUTH_STATE'
|| message.type === 'SUBMIT_ADD_EMAIL'
|| message.type === 'GET_STEP5_SUBMIT_STATE'
+ || message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT'
|| message.type === 'PREPARE_SIGNUP_VERIFICATION'
|| message.type === 'RECOVER_AUTH_RETRY_PAGE'
|| message.type === 'RECOVER_STEP5_SUBMIT_RETRY_PAGE'
@@ -135,6 +136,13 @@ async function handleCommand(message) {
return await submitAddEmailAndContinue(message.payload);
case 'GET_STEP5_SUBMIT_STATE':
return getStep5SubmitState();
+ case 'ADVANCE_STEP5_POST_SUBMIT_PROMPT': {
+ const advanced = await advanceStep5PostSubmitOnboardingPage({ allowProfileVisiblePrompt: true });
+ return {
+ advanced: Boolean(advanced),
+ state: getStep5SubmitState(),
+ };
+ }
case 'PREPARE_SIGNUP_VERIFICATION':
return await prepareSignupVerificationFlow(message.payload);
case 'RECOVER_AUTH_RETRY_PAGE':
@@ -249,9 +257,12 @@ function getVerificationCodeTarget() {
function getActionText(el) {
return [
el?.textContent,
+ el?.innerText,
el?.value,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
+ el?.getAttribute?.('data-testid'),
+ el?.getAttribute?.('data-dd-action-name'),
]
.filter(Boolean)
.join(' ')
@@ -2989,6 +3000,9 @@ function isStep5CompletionChatgptUrl(rawUrl = location.href) {
}
const path = String(parsed.pathname || '');
+ if (/^\/(?:onboarding|questionnaire|survey|personalization|personalize|getting-started|welcome|consult)(?:[/?#]|$)/i.test(path)) {
+ return false;
+ }
return !/^\/(?:auth\/|create-account\/|email-verification|log-in|add-phone)(?:[/?#]|$)/i.test(path);
} catch {
return false;
@@ -3021,6 +3035,29 @@ function getStep4PostVerificationState(options = {}) {
return null;
}
+async function advanceStep4PostVerificationInterstitialPage() {
+ if (typeof isStep5PostSubmitOnboardingPage !== 'function' || typeof findStep5PostSubmitOnboardingAction !== 'function') {
+ return false;
+ }
+ if (!isStep5PostSubmitOnboardingPage()) {
+ return false;
+ }
+
+ const action = findStep5PostSubmitOnboardingAction();
+ if (!action) {
+ return false;
+ }
+
+ const text = typeof getActionText === 'function'
+ ? getActionText(action)
+ : String(action?.textContent || action?.value || '').trim();
+ log(`步骤 4:检测到验证码通过后的弹窗/入门页面,正在点击“${text || '跳过/继续'}”直到进入步骤 5。`, 'warn');
+ await humanPause(350, 900);
+ simulateClick(action);
+ await sleep(1000);
+ return true;
+}
+
function getPageTextSnapshot() {
return (document.body?.innerText || document.body?.textContent || '')
.replace(/\s+/g, ' ')
@@ -5013,6 +5050,12 @@ async function waitForVerificationSubmitOutcome(step, timeout, options = {}) {
}
if (step === 4) {
+ if (
+ typeof advanceStep4PostVerificationInterstitialPage === 'function'
+ && await advanceStep4PostVerificationInterstitialPage()
+ ) {
+ continue;
+ }
const postVerificationState = getStep4PostVerificationState({ ignoreVerificationVisibility: true });
if (postVerificationState?.state === 'logged_in_home') {
return {
@@ -5056,6 +5099,23 @@ async function waitForVerificationSubmitOutcome(step, timeout, options = {}) {
throw createSignupUserAlreadyExistsError();
}
+ if (
+ typeof advanceStep4PostVerificationInterstitialPage === 'function'
+ && await advanceStep4PostVerificationInterstitialPage()
+ ) {
+ const postAdvanceState = getStep4PostVerificationState({ ignoreVerificationVisibility: true });
+ if (postAdvanceState?.state === 'logged_in_home') {
+ return {
+ success: true,
+ skipProfileStep: true,
+ url: postAdvanceState.url || location.href,
+ };
+ }
+ if (postAdvanceState?.state === 'step5') {
+ return { success: true };
+ }
+ }
+
const postVerificationState = getStep4PostVerificationState({ ignoreVerificationVisibility: true });
if (postVerificationState?.state === 'logged_in_home') {
return {
@@ -6745,6 +6805,10 @@ function getStep5PostSubmitSuccessState() {
return null;
}
+ if (typeof isStep5PostSubmitOnboardingPage === 'function' && isStep5PostSubmitOnboardingPage()) {
+ return null;
+ }
+
if (isStep5CompletionChatgptUrl()) {
return {
state: 'logged_in_home',
@@ -6755,6 +6819,111 @@ function getStep5PostSubmitSuccessState() {
return null;
}
+function isStep5PostSubmitOnboardingPage(options = {}) {
+ const allowProfileVisiblePrompt = Boolean(options?.allowProfileVisiblePrompt);
+ if (!allowProfileVisiblePrompt && isStep5ProfileStillVisible()) {
+ return false;
+ }
+
+ let host = '';
+ let path = '';
+ try {
+ const parsed = new URL(String(location.href || '').trim());
+ host = String(parsed.hostname || '').toLowerCase();
+ path = String(parsed.pathname || '').toLowerCase();
+ } catch {
+ return false;
+ }
+
+ if (!['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com', 'auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(host)) {
+ return false;
+ }
+
+ if (/\/(?:onboarding|questionnaire|survey|personalization|personalize|getting-started|welcome|consult)(?:[/?#]|$)/i.test(path)) {
+ return true;
+ }
+
+ const action = findStep5PostSubmitOnboardingAction();
+ if (!action) {
+ return false;
+ }
+
+ const pageText = typeof getPageTextSnapshot === 'function'
+ ? getPageTextSnapshot()
+ : String(document.body?.innerText || document.body?.textContent || '').replace(/\s+/g, ' ').trim();
+ return /(?:what\s+brings\s+you\s+to\s+chatgpt|tell\s+us\s+about\s+yourself|customi[sz]e\s+chatgpt|personalize\s+your\s+experience|how\s+will\s+you\s+use\s+chatgpt|which\s+best\s+describes\s+you|start\s+using\s+chatgpt|you(?:'|’)re\s+all\s+set|you\s+are\s+all\s+set|ready\s+to\s+go|chatgpt\s+may\s+make\s+mistakes|chats\s+may\s+be\s+reviewed|by\s+(?:continuing|clicking|selecting)\s+you\s+agree|terms\s+of\s+use|privacy\s+policy|accept\s+(?:all\s+)?(?:terms|cookies)|got\s+it|skip\s+for\s+now|你已准备就绪|你已準備就緒|已准备就绪|已準備就緒|可能会犯错|可能會犯錯|聊天可能会被审查|聊天可能會被審查|继续操作即表示你同意|繼續操作即表示你同意|点击即表示同意|點擊即表示同意|是什么促使你使用\s*chatgpt|是什麼促使你使用\s*chatgpt|我们会利用这些信息|我們會利用這些資訊|提出一些可能会对你有用的建议|學校|学校|工作|个人任务|個人任務|乐趣和娱乐|樂趣和娛樂|其他|条款|條款|隐私政策|隱私政策|服务条款|服務條款|使用条款|使用條款|接受|同意|确认|確認|知道了|我知道了|明白|入门|开始使用|告诉我们|个人化|个性化|问卷|调查|咨询|跳过|稍后|下一步|继续)/i.test(pageText)
+ && Boolean(action);
+}
+
+function findStep5PostSubmitOnboardingAction() {
+ const directButtons = Array.from(document.querySelectorAll('button, [role="button"]'));
+ const directSkipButton = directButtons.find((el) => {
+ if (!isVisibleElement(el) || !isActionEnabled(el)) return false;
+ return /^(?:跳过|稍后|以后|skip|not\s+now|maybe\s+later|do\s+this\s+later|スキップ|後で)$/i.test(getActionText(el));
+ });
+ if (directSkipButton) {
+ return directSkipButton;
+ }
+
+ const directContinueButton = directButtons.find((el) => {
+ if (!isVisibleElement(el) || !isActionEnabled(el)) return false;
+ return /^(?:继续|下一步|同意|确认|知道了|我知道了|好的|continue|next|agree|accept|confirm|got\s+it|ok|okay|done|finish)$/i.test(getActionText(el));
+ });
+ if (directContinueButton) {
+ return directContinueButton;
+ }
+
+ const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]'));
+ const scored = [];
+
+ for (const el of candidates) {
+ if (!isVisibleElement(el) || !isActionEnabled(el)) {
+ continue;
+ }
+ const text = typeof getActionText === 'function'
+ ? getActionText(el)
+ : [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')]
+ .filter(Boolean)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ if (!text) {
+ continue;
+ }
+ if (/(?:完成\s*(?:帐户|账户|账号)?\s*创建|创建\s*(?:帐户|账户|账号)|create\s+(?:an\s+)?account|create\s+your\s+account|complete\s+(?:account\s+)?creation|アカウント(?:を)?作成)/i.test(text)) {
+ continue;
+ }
+ if (/skip|not\s+now|maybe\s+later|do\s+this\s+later|稍后|以后|跳过|スキップ|後で/i.test(text)) {
+ scored.push({ el, score: 1 });
+ continue;
+ }
+ if (/accept|agree|allow|confirm|got\s+it|okay?|ok|i\s+understand|next|continue|start|done|finish|let'?s\s+go|同意|接受|允许|允許|确认|確認|知道了|我知道了|明白|好的|下一步|继续|继续使用|开始|完成|次へ|続行|始める|同意する|許可|確認/i.test(text)) {
+ scored.push({ el, score: 2 });
+ }
+ }
+
+ scored.sort((a, b) => a.score - b.score);
+ return scored[0]?.el || null;
+}
+
+async function advanceStep5PostSubmitOnboardingPage(options = {}) {
+ if (!isStep5PostSubmitOnboardingPage(options)) {
+ return false;
+ }
+
+ const action = findStep5PostSubmitOnboardingAction();
+ if (!action) {
+ return false;
+ }
+
+ const text = typeof getActionText === 'function' ? getActionText(action) : String(action?.textContent || action?.value || '').trim();
+ log(`步骤 5:检测到注册后的咨询/入门页面,正在点击“${text || '跳过/下一步'}”继续流程。`, 'info');
+ await humanPause(350, 900);
+ simulateClick(action);
+ await sleep(1000);
+ return true;
+}
+
function getStep5SubmitState() {
const retryState = getStep5AuthRetryPageState();
const successState = getStep5PostSubmitSuccessState();
@@ -6839,7 +7008,7 @@ function installStep5NavigationCompletionReporter(completeOnce) {
const onNavigationStarted = (event) => {
const eventType = String(event?.type || 'navigation').trim() || 'navigation';
debugLog(`检测到页面开始导航(event=${eventType})。`, {
- level: 'warn',
+ level: 'info',
});
};
@@ -6866,11 +7035,14 @@ async function waitForStep5SubmitOutcome(options = {}) {
const {
timeoutMs = 120000,
maxAuthRetryRecoveries = 2,
+ maxPostSubmitPromptActions = 3,
maxSubmitClicks = 3,
retryClickIntervalMs = 3500,
} = options;
const start = Date.now();
let authRetryRecoveryCount = 0;
+ let postSubmitPromptActionCount = 0;
+ const minPostSubmitPromptActions = Math.min(maxPostSubmitPromptActions, Math.max(0, Number(options?.minPostSubmitPromptActions) || 2));
let submitClickCount = 1;
let lastSubmitClickAt = Date.now();
let lastStep5Error = '';
@@ -6909,8 +7081,33 @@ async function waitForStep5SubmitOutcome(options = {}) {
continue;
}
+ if (postSubmitPromptActionCount < maxPostSubmitPromptActions && await advanceStep5PostSubmitOnboardingPage({ allowProfileVisiblePrompt: true })) {
+ postSubmitPromptActionCount += 1;
+ lastSubmitClickAt = Date.now();
+ const url = String(location.href || '').trim();
+ debugLog(`注册后弹窗已处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次。`, {
+ level: 'ok',
+ });
+ if (postSubmitPromptActionCount < maxPostSubmitPromptActions) {
+ continue;
+ }
+ debugLog(`注册后弹窗已处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,按已完成账号注册进入后续步骤。`, {
+ level: 'ok',
+ });
+ return {
+ state: isStep5CompletionChatgptUrl(url) ? 'logged_in_home' : 'post_submit_prompts_completed',
+ url,
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount,
+ };
+ }
+
const successState = getStep5PostSubmitSuccessState();
if (successState) {
+ if (postSubmitPromptActionCount > 0 && postSubmitPromptActionCount < maxPostSubmitPromptActions) {
+ await sleep(250);
+ continue;
+ }
debugLog(`检测到资料提交成功状态:${successState.state || 'unknown'}`, {
level: 'ok',
});
@@ -6955,9 +7152,38 @@ async function waitForStep5SubmitOutcome(options = {}) {
const finalSuccessState = getStep5PostSubmitSuccessState();
if (finalSuccessState) {
+ if (postSubmitPromptActionCount > 0 && postSubmitPromptActionCount < minPostSubmitPromptActions) {
+ throw new Error(`步骤 5:注册后弹窗仅处理 ${postSubmitPromptActionCount}/${minPostSubmitPromptActions} 次,尚未达到完成门槛。URL: ${location.href}`);
+ }
+ if (postSubmitPromptActionCount >= minPostSubmitPromptActions && postSubmitPromptActionCount < maxPostSubmitPromptActions) {
+ const url = String(location.href || '').trim();
+ debugLog(`注册后弹窗已处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,最后一轮未检测到新弹窗,忽略等待超时并按成功进入后续步骤。`, {
+ level: 'ok',
+ });
+ return {
+ ...finalSuccessState,
+ state: finalSuccessState.state || (isStep5CompletionChatgptUrl(url) ? 'logged_in_home' : 'post_submit_prompts_completed'),
+ url: finalSuccessState.url || url,
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount,
+ };
+ }
return finalSuccessState;
}
+ if (postSubmitPromptActionCount >= minPostSubmitPromptActions) {
+ const url = String(location.href || '').trim();
+ debugLog(`注册后弹窗已处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,最后一轮未检测到新弹窗,忽略等待超时并按成功进入后续步骤。`, {
+ level: 'ok',
+ });
+ return {
+ state: isStep5CompletionChatgptUrl(url) ? 'logged_in_home' : 'post_submit_prompts_completed',
+ url,
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount,
+ };
+ }
+
const finalStep5Error = (typeof getStep5ErrorText === 'function' ? getStep5ErrorText() : '') || lastStep5Error;
if (finalStep5Error) {
throw new Error(`步骤 5:资料提交后页面返回错误:${finalStep5Error}。URL: ${location.href}`);
@@ -6968,6 +7194,7 @@ async function waitForStep5SubmitOutcome(options = {}) {
async function step5_fillNameBirthday(payload) {
const { firstName, lastName, age, year, month, day, prefillOnly = false } = payload;
+ const completionToken = String(payload?.completionToken || '').trim();
if (!firstName || !lastName) throw new Error('未提供姓名数据。');
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
@@ -7242,6 +7469,9 @@ async function step5_fillNameBirthday(payload) {
navigationStarted: Boolean(extra.navigationStarted),
outcome: extra.outcome || null,
});
+ if (completionToken) {
+ completionPayload.completionToken = completionToken;
+ }
debugLog(`准备发送完成信号(reason=${completionReason},isAgeMode=${isAgeMode})。`, {
level: extra?.navigationStarted ? 'warn' : 'info',
});
diff --git a/scripts/custom_mail_helper.py b/scripts/custom_mail_helper.py
new file mode 100644
index 00000000..b6e4d8b0
--- /dev/null
+++ b/scripts/custom_mail_helper.py
@@ -0,0 +1,428 @@
+import email
+import html
+import imaplib
+import json
+import os
+import re
+import secrets
+import ssl
+import sqlite3
+import string
+import traceback
+from datetime import datetime, timezone
+from email.header import decode_header
+from email.utils import getaddresses, parseaddr, parsedate_to_datetime
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+
+def load_dotenv_file(path):
+ if not os.path.exists(path):
+ return
+ with open(path, "r", encoding="utf-8") as env_file:
+ for raw_line in env_file:
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ key = key.strip()
+ if not key or key in os.environ:
+ continue
+ value = value.strip()
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
+ value = value[1:-1]
+ os.environ[key] = value
+
+
+PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+load_dotenv_file(os.path.join(PROJECT_ROOT, ".env"))
+
+
+HOST = "127.0.0.1"
+PORT = int(os.environ.get("FLOWPILOT_CUSTOM_MAIL_HELPER_PORT", "17374"))
+IMAP_HOST = os.environ.get("FLOWPILOT_CUSTOM_IMAP_HOST", "imap.mxhichina.com")
+IMAP_PORT = int(os.environ.get("FLOWPILOT_CUSTOM_IMAP_PORT", "993"))
+IMAP_USER = os.environ.get("FLOWPILOT_CUSTOM_IMAP_USER", "")
+IMAP_PASS = os.environ.get("FLOWPILOT_CUSTOM_IMAP_PASS", "")
+IMAP_MAILBOX = os.environ.get("FLOWPILOT_CUSTOM_IMAP_MAILBOX", "INBOX")
+REQUEST_TIMEOUT_SECONDS = int(os.environ.get("FLOWPILOT_CUSTOM_IMAP_TIMEOUT", "45"))
+DEFAULT_TOP = 20
+RANDOM_EMAIL_DB_PATH = os.environ.get("FLOWPILOT_RANDOM_EMAIL_DB_PATH", os.path.join(PROJECT_ROOT, "data", "custom-mail-helper.sqlite3"))
+RANDOM_EMAIL_MAX_COUNT = int(os.environ.get("FLOWPILOT_RANDOM_EMAIL_MAX_COUNT", "20"))
+PUBLIC_ENV_KEYS = [
+ "FLOWPILOT_SUB2API_REDIRECT_URI",
+]
+DEFAULT_MAIL_FROM_ALLOW = [
+ "no-reply@codeium.com",
+ "noreply@codeium.com",
+ "no-reply@windsurf.com",
+ "noreply@windsurf.com",
+ "noreply@tm.openai.com",
+ "noreply@tm1.openai.com",
+]
+
+
+def json_response(handler, status, payload):
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ handler.send_response(status)
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
+ handler.send_header("Content-Length", str(len(body)))
+ handler.send_header("Access-Control-Allow-Origin", "*")
+ handler.send_header("Access-Control-Allow-Headers", "Content-Type")
+ handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
+ handler.end_headers()
+ handler.wfile.write(body)
+
+
+def read_json_payload(handler):
+ length = int(handler.headers.get("Content-Length", "0") or 0)
+ raw = handler.rfile.read(length) if length > 0 else b"{}"
+ try:
+ return json.loads(raw.decode("utf-8"))
+ except Exception as exc:
+ raise RuntimeError(f"Invalid JSON payload: {exc}") from exc
+
+
+def get_public_env_payload():
+ return {key: os.environ.get(key, "") for key in PUBLIC_ENV_KEYS}
+
+
+def decode_mime_header(value):
+ if not value:
+ return ""
+ parts = []
+ for chunk, charset in decode_header(value):
+ if isinstance(chunk, bytes):
+ parts.append(chunk.decode(charset or "utf-8", errors="ignore"))
+ else:
+ parts.append(str(chunk))
+ return "".join(parts).strip()
+
+
+def extract_text_part(message):
+ if message.is_multipart():
+ html_text = ""
+ for part in message.walk():
+ if part.get_content_maintype() == "multipart":
+ continue
+ if "attachment" in str(part.get("Content-Disposition") or "").lower():
+ continue
+ payload = part.get_payload(decode=True) or b""
+ charset = part.get_content_charset() or "utf-8"
+ text = payload.decode(charset, errors="ignore").strip()
+ if part.get_content_type() == "text/plain" and text:
+ return text
+ if part.get_content_type() == "text/html" and text and not html_text:
+ html_text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
+ return html_text
+
+ payload = message.get_payload(decode=True) or b""
+ charset = message.get_content_charset() or "utf-8"
+ text = payload.decode(charset, errors="ignore").strip()
+ if message.get_content_type() == "text/html":
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
+ return text
+
+
+def to_timestamp_ms(raw_date):
+ if not raw_date:
+ return 0
+ try:
+ parsed = parsedate_to_datetime(raw_date)
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return int(parsed.timestamp() * 1000)
+ except Exception:
+ return 0
+
+
+def to_iso_string(timestamp_ms):
+ if not timestamp_ms:
+ return ""
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def parse_addresses(value):
+ return [addr.strip().lower() for _, addr in getaddresses([str(value or "")]) if addr.strip()]
+
+
+def normalize_domain(value):
+ domain = str(value or "").strip().lower()
+ if domain.startswith("@"):
+ domain = domain[1:]
+ if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+", domain):
+ raise RuntimeError("Invalid domain")
+ return domain
+
+
+def parse_generation_count(value):
+ if value in (None, ""):
+ return 1
+ try:
+ count = int(value)
+ except Exception as exc:
+ raise RuntimeError("n must be an integer") from exc
+ if count < 1:
+ raise RuntimeError("n must be >= 1")
+ if count > RANDOM_EMAIL_MAX_COUNT:
+ raise RuntimeError(f"n must be <= {RANDOM_EMAIL_MAX_COUNT}")
+ return count
+
+
+def ensure_random_email_db():
+ db_dir = os.path.dirname(RANDOM_EMAIL_DB_PATH)
+ if db_dir:
+ os.makedirs(db_dir, exist_ok=True)
+ connection = sqlite3.connect(RANDOM_EMAIL_DB_PATH)
+ try:
+ connection.execute("""
+ CREATE TABLE IF NOT EXISTS generated_emails (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL UNIQUE,
+ prefix TEXT NOT NULL,
+ domain TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ connection.execute("CREATE INDEX IF NOT EXISTS idx_generated_emails_domain ON generated_emails(domain)")
+ connection.commit()
+ return connection
+ except Exception:
+ connection.close()
+ raise
+
+
+def random_email_prefix():
+ length = secrets.randbelow(5) + 8
+ alphabet = string.ascii_lowercase
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+
+def generate_random_emails(payload):
+ domain = normalize_domain((payload or {}).get("domain"))
+ count = parse_generation_count((payload or {}).get("n"))
+ emails = []
+ connection = ensure_random_email_db()
+ try:
+ attempts = 0
+ max_attempts = max(100, count * 20)
+ while len(emails) < count and attempts < max_attempts:
+ attempts += 1
+ prefix = random_email_prefix()
+ email_address = f"{prefix}@{domain}"
+ try:
+ connection.execute(
+ "INSERT INTO generated_emails(email, prefix, domain) VALUES (?, ?, ?)",
+ (email_address, prefix, domain),
+ )
+ emails.append(email_address)
+ except sqlite3.IntegrityError:
+ continue
+ if len(emails) != count:
+ connection.rollback()
+ raise RuntimeError("Unable to generate enough unique emails")
+ connection.commit()
+ return {"email": emails[0] if emails else "", "emails": emails, "domain": domain, "count": len(emails)}
+ finally:
+ connection.close()
+
+
+def normalize_message(message_id, raw_bytes):
+ parsed = email.message_from_bytes(raw_bytes)
+ sender_name, sender_addr = parseaddr(parsed.get("From", ""))
+ subject = decode_mime_header(parsed.get("Subject", ""))
+ body = extract_text_part(parsed)
+ timestamp_ms = to_timestamp_ms(parsed.get("Date"))
+ return {
+ "id": str(message_id),
+ "mailbox": IMAP_MAILBOX,
+ "subject": subject,
+ "from": {
+ "emailAddress": {
+ "address": sender_addr.strip().lower(),
+ "name": sender_name.strip(),
+ }
+ },
+ "to": parse_addresses(parsed.get("To", "")),
+ "cc": parse_addresses(parsed.get("Cc", "")),
+ "deliveredTo": parse_addresses(parsed.get("Delivered-To", "")),
+ "bodyPreview": body[:500],
+ "body": {"content": body},
+ "receivedDateTime": to_iso_string(timestamp_ms),
+ "receivedTimestamp": timestamp_ms,
+ }
+
+
+def fetch_recent_messages(top=DEFAULT_TOP):
+ if not IMAP_USER or not IMAP_PASS:
+ raise RuntimeError("Missing FLOWPILOT_CUSTOM_IMAP_USER/FLOWPILOT_CUSTOM_IMAP_PASS")
+
+ context = ssl.create_default_context()
+ client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context, timeout=REQUEST_TIMEOUT_SECONDS)
+ try:
+ client.login(IMAP_USER, IMAP_PASS)
+ status, _ = client.select(IMAP_MAILBOX)
+ if status != "OK":
+ raise RuntimeError(f"Mailbox not found: {IMAP_MAILBOX}")
+ status, data = client.search(None, "ALL")
+ if status != "OK" or not data or not data[0]:
+ return []
+
+ message_ids = data[0].split()
+ selected_ids = list(reversed(message_ids[-max(1, min(int(top or DEFAULT_TOP), 50)):]))
+ messages = []
+ for message_id in selected_ids:
+ fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
+ if fetch_status != "OK" or not fetch_data:
+ continue
+ raw_bytes = b""
+ for item in fetch_data:
+ if isinstance(item, tuple) and len(item) >= 2:
+ raw_bytes = item[1]
+ break
+ if raw_bytes:
+ messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes))
+ messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
+ return messages
+ finally:
+ try:
+ client.logout()
+ except Exception:
+ pass
+
+
+def extract_code(text, code_patterns=None):
+ source = str(text or "")
+ for pattern in code_patterns or []:
+ try:
+ source_pattern = str((pattern or {}).get("source") or "").strip()
+ if not source_pattern:
+ continue
+ flags = str((pattern or {}).get("flags") or "").lower()
+ re_flags = 0
+ if "i" in flags:
+ re_flags |= re.IGNORECASE
+ if "m" in flags:
+ re_flags |= re.MULTILINE
+ if "s" in flags:
+ re_flags |= re.DOTALL
+ match = re.search(source_pattern, source, flags=re_flags)
+ if match:
+ return next((str(match.group(i) or "").strip() for i in range(1, (match.lastindex or 0) + 1) if str(match.group(i) or "").strip()), str(match.group(0) or "").strip())
+ except re.error:
+ continue
+ for pattern in [
+ r"(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})",
+ r"(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})",
+ r"code(?:\s+is|[\s:])+(\d{6})",
+ r"\b(\d{6})\b",
+ ]:
+ match = re.search(pattern, source, flags=re.IGNORECASE)
+ if match:
+ return match.group(1)
+ return ""
+
+
+def message_matches_target(message, target_email):
+ target = str(target_email or "").strip().lower()
+ if not target:
+ return True
+ recipients = set(message.get("to") or []) | set(message.get("cc") or []) | set(message.get("deliveredTo") or [])
+ return target in recipients
+
+
+def select_latest_code(messages, payload):
+ target_email = str(payload.get("targetEmail") or "").strip().lower()
+ filter_after_timestamp = int(payload.get("filterAfterTimestamp") or 0)
+ excluded = {str(item).strip() for item in payload.get("excludeCodes") or [] if str(item).strip()}
+ sender_filters = [str(item).strip().lower() for item in payload.get("senderFilters") or [] if str(item).strip()]
+ if not sender_filters:
+ sender_filters = DEFAULT_MAIL_FROM_ALLOW
+ subject_filters = [str(item).strip().lower() for item in payload.get("subjectFilters") or [] if str(item).strip()]
+ required_keywords = [str(item).strip().lower() for item in payload.get("requiredKeywords") or [] if str(item).strip()]
+
+ def candidate(message, apply_time_filter):
+ timestamp = int(message.get("receivedTimestamp") or 0)
+ if apply_time_filter and filter_after_timestamp and timestamp and timestamp < filter_after_timestamp:
+ return None
+ if not message_matches_target(message, target_email):
+ return None
+ sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
+ subject = str(message.get("subject") or "")
+ preview = str(message.get("bodyPreview") or "")
+ body = str((message.get("body") or {}).get("content") or "")
+ combined = " ".join([sender, subject, preview, body]).lower()
+ if sender_filters and sender not in sender_filters and not any(item in combined for item in sender_filters):
+ return None
+ if subject_filters and not any(item in combined for item in subject_filters):
+ return None
+ if required_keywords and not any(item in combined for item in required_keywords):
+ return None
+ code = extract_code("\n".join([subject, preview, body, sender]), payload.get("codePatterns") or [])
+ if not code or code in excluded:
+ return None
+ return {"code": code, "message": message}
+
+ for use_time_fallback in [False, True]:
+ matches = [item for item in (candidate(message, not use_time_fallback) for message in messages) if item]
+ if matches:
+ matches.sort(key=lambda item: int(item["message"].get("receivedTimestamp") or 0), reverse=True)
+ best = matches[0]
+ return {"code": best["code"], "message": best["message"], "usedTimeFallback": use_time_fallback}
+ return {"code": "", "message": None, "usedTimeFallback": False}
+
+
+class CustomMailHelperHandler(BaseHTTPRequestHandler):
+ def do_OPTIONS(self):
+ self.send_response(204)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
+ self.end_headers()
+
+ def do_POST(self):
+ try:
+ payload = read_json_payload(self)
+ if self.path == "/messages":
+ messages = fetch_recent_messages(payload.get("top") or DEFAULT_TOP)
+ json_response(self, 200, {"ok": True, "messages": messages})
+ return
+ if self.path == "/code":
+ messages = fetch_recent_messages(payload.get("top") or DEFAULT_TOP)
+ selected = select_latest_code(messages, payload)
+ json_response(self, 200, {
+ "ok": True,
+ "code": selected["code"],
+ "message": selected["message"],
+ "usedTimeFallback": selected["usedTimeFallback"],
+ })
+ return
+ if self.path == "/health":
+ json_response(self, 200, {"ok": True})
+ return
+ if self.path == "/env":
+ json_response(self, 200, {"ok": True, "env": get_public_env_payload()})
+ return
+ if self.path == "/random-email":
+ result = generate_random_emails(payload)
+ json_response(self, 200, {"ok": True, **result})
+ return
+ json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
+ except Exception as exc:
+ traceback.print_exc()
+ json_response(self, 500, {"ok": False, "error": str(exc)})
+
+
+def main():
+ server = ThreadingHTTPServer((HOST, PORT), CustomMailHelperHandler)
+ print(f"Custom mail helper listening on http://{HOST}:{PORT}", flush=True)
+ print(f"IMAP host={IMAP_HOST}:{IMAP_PORT} user={IMAP_USER or '(unset)'} mailbox={IMAP_MAILBOX}", flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html
index ccc80e7b..3eadc45a 100644
--- a/sidepanel/sidepanel.html
+++ b/sidepanel/sidepanel.html
@@ -1500,6 +1500,15 @@
+
+
运营商
+
+
+ 按主国家从 HeroSMS 接口获取;不限时不附加 operator 参数。
+
+
接码 API
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index 661f81f0..b20f80ca 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -437,6 +437,7 @@ const rowHeroSmsPlatform = document.getElementById('row-hero-sms-platform');
const rowHeroSmsCountry = document.getElementById('row-hero-sms-country');
const rowHeroSmsCountryFallback = document.getElementById('row-hero-sms-country-fallback');
const rowHeroSmsAcquirePriority = document.getElementById('row-hero-sms-acquire-priority');
+const rowHeroSmsOperator = document.getElementById('row-hero-sms-operator');
const rowHeroSmsApiKey = document.getElementById('row-hero-sms-api-key');
const rowHeroSmsMaxPrice = document.getElementById('row-hero-sms-max-price');
const rowPhoneSmsProvider = document.getElementById('row-phone-sms-provider');
@@ -491,6 +492,7 @@ const inputFreeReusablePhone = document.getElementById('input-free-reusable-phon
const selectHeroSmsCountry = document.getElementById('select-hero-sms-country');
const selectHeroSmsCountryFallback = document.getElementById('select-hero-sms-country-fallback');
const selectHeroSmsAcquirePriority = document.getElementById('select-hero-sms-acquire-priority');
+const selectHeroSmsOperator = document.getElementById('select-hero-sms-operator');
const selectHeroSmsPreferredActivation = document.getElementById('select-hero-sms-preferred-activation');
const selectFiveSimCountry = document.getElementById('select-five-sim-country');
const heroSmsCountryMenuShell = document.getElementById('hero-sms-country-menu-shell');
@@ -582,6 +584,9 @@ let currentStepDefinitionFlowId = DEFAULT_ACTIVE_FLOW_ID;
let phoneSignupReuseUiWasLocked = false;
let kiroRsConnectionTestStatusText = '未测试';
let heroSmsCountrySelectionOrder = [];
+let heroSmsOperatorsByCountryId = new Map();
+let heroSmsOperatorsLoadedAt = 0;
+let isRenderingHeroSmsOperatorOptions = false;
let phoneSmsProviderOrderSelection = [];
let heroSmsCountryMenuSearchKeyword = '';
const heroSmsCountrySearchTextById = new Map();
@@ -657,6 +662,8 @@ const DEFAULT_FIVE_SIM_PRODUCT = 'openai';
const DEFAULT_NEX_SMS_COUNTRY_ORDER = Object.freeze([1]);
const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot';
const HERO_SMS_COUNTRY_SELECTION_MAX = 3;
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
+const HERO_SMS_OPERATORS_URL = 'https://hero-sms.com/stubs/handler_api.php?action=getOperators';
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
@@ -2963,6 +2970,22 @@ function syncLatestState(nextState) {
normalizedNextState.activeFlowId = normalizedFlowId;
normalizedNextState.flowId = normalizedFlowId;
}
+
+ const shouldSyncStepDefinitions = [
+ 'activeFlowId',
+ 'flowId',
+ 'targetId',
+ 'plusModeEnabled',
+ 'plusPaymentMethod',
+ 'plusAccountAccessStrategy',
+ 'signupMethod',
+ 'phoneSignupReloginAfterBindEmailEnabled',
+ 'accountContributionEnabled',
+ ].some((key) => Object.prototype.hasOwnProperty.call(normalizedNextState, key));
+ if (shouldSyncStepDefinitions && typeof syncStepDefinitionsFromUiState === 'function') {
+ syncStepDefinitionsFromUiState(normalizedNextState);
+ }
+
const mergedNodeStatuses = normalizedNextState?.nodeStatuses
? getStoredNodeStatuses({
nodeStatuses: { ...NODE_DEFAULT_STATUSES, ...(latestState?.nodeStatuses || {}), ...normalizedNextState.nodeStatuses },
@@ -4524,6 +4547,12 @@ function collectSettingsPayload() {
const fiveSimOperatorValue = typeof inputFiveSimOperator !== 'undefined' && inputFiveSimOperator
? normalizeFiveSimOperator(inputFiveSimOperator.value || latestState?.fiveSimOperator)
: normalizeFiveSimOperator(latestState?.fiveSimOperator);
+ const normalizeHeroSmsOperatorForPayload = typeof normalizeHeroSmsOperatorValue === 'function'
+ ? normalizeHeroSmsOperatorValue
+ : ((value = '', fallback = 'any') => String(value || fallback || 'any').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || 'any');
+ const heroSmsOperatorValue = typeof selectHeroSmsOperator !== 'undefined' && selectHeroSmsOperator
+ ? normalizeHeroSmsOperatorForPayload(selectHeroSmsOperator.value || latestState?.heroSmsOperator)
+ : normalizeHeroSmsOperatorForPayload(latestState?.heroSmsOperator);
const fiveSimProductValue = typeof inputFiveSimProduct !== 'undefined' && inputFiveSimProduct
? normalizeFiveSimProductForPayload(inputFiveSimProduct.value || latestState?.fiveSimProduct)
: normalizeFiveSimProductForPayload(latestState?.fiveSimProduct || defaultFiveSimProduct);
@@ -5044,6 +5073,7 @@ function collectSettingsPayload() {
heroSmsCountryId: heroSmsCountry.id,
heroSmsCountryLabel: heroSmsCountry.label,
heroSmsCountryFallback,
+ heroSmsOperator: heroSmsOperatorValue,
fiveSimCountryId: fiveSimCountry.id,
fiveSimCountryLabel: fiveSimCountry.label,
fiveSimCountryFallback,
@@ -5297,6 +5327,21 @@ function normalizeHeroSmsCountryLabel(value = '') {
return String(value || '').trim() || DEFAULT_HERO_SMS_COUNTRY_LABEL;
}
+function normalizeHeroSmsOperatorValue(value = '', fallback = DEFAULT_HERO_SMS_OPERATOR) {
+ const normalized = String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '');
+ if (normalized) {
+ return normalized;
+ }
+ const fallbackNormalized = String(fallback || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '');
+ return fallbackNormalized || DEFAULT_HERO_SMS_OPERATOR;
+}
+
function normalizeHeroSmsMaxPriceValue(value = '') {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
@@ -6053,6 +6098,35 @@ function parseHeroSmsCountryPayload(payload) {
return [];
}
+function parseHeroSmsOperatorsPayload(payload) {
+ const source = payload?.countryOperators || payload?.operators || payload?.data || payload?.result || payload;
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
+ return new Map();
+ }
+ const result = new Map();
+ Object.entries(source).forEach(([countryId, operators]) => {
+ const normalizedCountryId = Math.floor(Number(countryId));
+ if (normalizedCountryId <= 0 || !Array.isArray(operators)) {
+ return;
+ }
+ const seen = new Set();
+ const normalizedOperators = operators
+ .map((operator) => normalizeHeroSmsOperatorValue(operator, ''))
+ .filter(Boolean)
+ .filter((operator) => {
+ if (seen.has(operator)) {
+ return false;
+ }
+ seen.add(operator);
+ return true;
+ });
+ if (normalizedOperators.length) {
+ result.set(String(normalizedCountryId), normalizedOperators);
+ }
+ });
+ return result;
+}
+
function normalizeHeroSmsPriceForPreview(value) {
const direct = Number(value);
if (Number.isFinite(direct) && direct >= 0) {
@@ -6564,6 +6638,34 @@ function getSelectedHeroSmsCountryOption() {
: { id: DEFAULT_HERO_SMS_COUNTRY_ID, label: DEFAULT_HERO_SMS_COUNTRY_LABEL };
}
+function peekSelectedHeroSmsCountryOption() {
+ const countrySelect = selectHeroSmsCountry || selectHeroSmsCountryFallback;
+ const selectedId = heroSmsCountrySelectionOrder.length
+ ? heroSmsCountrySelectionOrder[0]
+ : null;
+ if (selectedId !== null && selectedId !== undefined && selectedId !== '') {
+ const id = normalizeHeroSmsCountryId(selectedId, DEFAULT_HERO_SMS_COUNTRY_ID);
+ return {
+ id,
+ label: getHeroSmsCountryLabelById(String(id)) || normalizeHeroSmsCountryLabel(latestState?.heroSmsCountryLabel),
+ };
+ }
+ const selectedOption = countrySelect
+ ? Array.from(countrySelect.options || []).find((option) => option.selected)
+ : null;
+ if (selectedOption) {
+ const id = normalizeHeroSmsCountryId(selectedOption.value, DEFAULT_HERO_SMS_COUNTRY_ID);
+ return {
+ id,
+ label: String(selectedOption.textContent || '').trim() || `Country #${id}`,
+ };
+ }
+ return {
+ id: normalizeHeroSmsCountryId(latestState?.heroSmsCountryId, DEFAULT_HERO_SMS_COUNTRY_ID),
+ label: normalizeHeroSmsCountryLabel(latestState?.heroSmsCountryLabel),
+ };
+}
+
function getFiveSimCountryOptionLabel(code = '') {
const normalizedCode = normalizeFiveSimCountryCode(code, '');
if (!normalizedCode) {
@@ -6613,7 +6715,7 @@ function updateHeroSmsPlatformDisplay() {
? (getSelectedFiveSimCountries()[0] || { id: DEFAULT_FIVE_SIM_COUNTRY_ID, label: DEFAULT_FIVE_SIM_COUNTRY_LABEL })
: (provider === PHONE_SMS_PROVIDER_NEXSMS
? (getSelectedNexSmsCountries()[0] || { id: DEFAULT_NEX_SMS_COUNTRY_ORDER[0], label: `Country #${DEFAULT_NEX_SMS_COUNTRY_ORDER[0]}` })
- : getSelectedHeroSmsCountryOption());
+ : peekSelectedHeroSmsCountryOption());
const countryText = selected?.label ? ` / ${selected.label}` : '';
displayHeroSmsPlatform.textContent = `${getPhoneSmsProviderLabel(provider)} / OpenAI${countryText}`;
if (inputHeroSmsApiKey) {
@@ -6801,6 +6903,9 @@ function renderHeroSmsCountryChoiceButtons() {
showLimitToast: true,
});
updateHeroSmsPlatformDisplay();
+ if (typeof refreshHeroSmsOperatorOptions === 'function') {
+ refreshHeroSmsOperatorOptions({ silent: true });
+ }
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
});
@@ -6938,6 +7043,9 @@ function removeHeroSmsCountryFromOrder(id) {
showLimitToast: false,
});
updateHeroSmsPlatformDisplay();
+ if (typeof refreshHeroSmsOperatorOptions === 'function') {
+ refreshHeroSmsOperatorOptions({ silent: true });
+ }
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
return nextOrder;
@@ -7423,6 +7531,133 @@ async function loadHeroSmsCountries(options = {}) {
showLimitToast: false,
});
updateHeroSmsPlatformDisplay();
+ setHeroSmsOperatorSelectValue(latestState?.heroSmsOperator);
+}
+
+function refreshHeroSmsOperatorOptions(options = {}) {
+ if (!selectHeroSmsOperator) {
+ return Promise.resolve(heroSmsOperatorsByCountryId);
+ }
+ return loadHeroSmsOperators(options)
+ .then(() => {
+ renderHeroSmsOperatorOptions(options?.selectedOperator);
+ return heroSmsOperatorsByCountryId;
+ })
+ .catch(() => {
+ renderHeroSmsOperatorOptions(options?.selectedOperator);
+ return heroSmsOperatorsByCountryId;
+ });
+}
+
+function getHeroSmsOperatorCountryId() {
+ const countrySelect = selectHeroSmsCountry || selectHeroSmsCountryFallback;
+ if (heroSmsCountrySelectionOrder.length) {
+ return normalizeHeroSmsCountryId(heroSmsCountrySelectionOrder[0], DEFAULT_HERO_SMS_COUNTRY_ID);
+ }
+ const selectedOption = countrySelect
+ ? Array.from(countrySelect.options || []).find((option) => option.selected)
+ : null;
+ return normalizeHeroSmsCountryId(
+ selectedOption?.value || latestState?.heroSmsCountryId,
+ DEFAULT_HERO_SMS_COUNTRY_ID
+ );
+}
+
+async function loadHeroSmsOperators(options = {}) {
+ const silent = Boolean(options?.silent);
+ const force = Boolean(options?.force);
+ if (!force && heroSmsOperatorsByCountryId instanceof Map && heroSmsOperatorsByCountryId.size && Date.now() - heroSmsOperatorsLoadedAt < 10 * 60 * 1000) {
+ return heroSmsOperatorsByCountryId;
+ }
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 3500);
+ const response = await fetch(HERO_SMS_OPERATORS_URL, {
+ signal: controller.signal,
+ cache: 'no-store',
+ headers: { Accept: 'application/json' },
+ });
+ clearTimeout(timeoutId);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const payload = await response.json();
+ const parsed = parseHeroSmsOperatorsPayload(payload);
+ if (!parsed.size) {
+ throw new Error('运营商列表为空');
+ }
+ heroSmsOperatorsByCountryId = parsed;
+ heroSmsOperatorsLoadedAt = Date.now();
+ } catch (error) {
+ if (!(heroSmsOperatorsByCountryId instanceof Map)) {
+ heroSmsOperatorsByCountryId = new Map();
+ }
+ if (!silent && typeof showToast === 'function') {
+ showToast(`HeroSMS 运营商列表加载失败:${normalizeHeroSmsFetchErrorMessage(error)}(已保留“不限”)`, 'warn', 2600);
+ }
+ }
+ return heroSmsOperatorsByCountryId;
+}
+
+function renderHeroSmsOperatorOptions(selectedOperator = null) {
+ if (!selectHeroSmsOperator || isRenderingHeroSmsOperatorOptions) {
+ return;
+ }
+ isRenderingHeroSmsOperatorOptions = true;
+ try {
+ const currentValue = normalizeHeroSmsOperatorValue(
+ selectedOperator !== null && selectedOperator !== undefined
+ ? selectedOperator
+ : (selectHeroSmsOperator.value || latestState?.heroSmsOperator),
+ DEFAULT_HERO_SMS_OPERATOR
+ );
+ const countryId = String(getHeroSmsOperatorCountryId());
+ const operators = heroSmsOperatorsByCountryId instanceof Map
+ ? (heroSmsOperatorsByCountryId.get(countryId) || [])
+ : [];
+ selectHeroSmsOperator.innerHTML = '';
+ const anyOption = document.createElement('option');
+ anyOption.value = DEFAULT_HERO_SMS_OPERATOR;
+ anyOption.textContent = '不限(any)';
+ selectHeroSmsOperator.appendChild(anyOption);
+ operators.forEach((operator) => {
+ const option = document.createElement('option');
+ option.value = operator;
+ option.textContent = operator;
+ selectHeroSmsOperator.appendChild(option);
+ });
+ const hasCurrent = Array.from(selectHeroSmsOperator.options).some((option) => option.value === currentValue);
+ if (!hasCurrent && currentValue && currentValue !== DEFAULT_HERO_SMS_OPERATOR) {
+ const selectedOption = typeof document !== 'undefined' && document?.createElement
+ ? document.createElement('option')
+ : { value: '', textContent: '' };
+ selectedOption.value = currentValue;
+ selectedOption.textContent = currentValue;
+ selectHeroSmsOperator.appendChild(selectedOption);
+ }
+ selectHeroSmsOperator.value = currentValue || DEFAULT_HERO_SMS_OPERATOR;
+ selectHeroSmsOperator.disabled = operators.length === 0;
+ } finally {
+ isRenderingHeroSmsOperatorOptions = false;
+ }
+}
+
+function setHeroSmsOperatorSelectValue(operator = latestState?.heroSmsOperator) {
+ if (!selectHeroSmsOperator) {
+ return DEFAULT_HERO_SMS_OPERATOR;
+ }
+ const normalized = normalizeHeroSmsOperatorValue(operator, DEFAULT_HERO_SMS_OPERATOR);
+ const hasOption = Array.from(selectHeroSmsOperator.options || []).some((option) => option.value === normalized);
+ if (!hasOption && normalized && normalized !== DEFAULT_HERO_SMS_OPERATOR) {
+ const selectedOption = typeof document !== 'undefined' && document?.createElement
+ ? document.createElement('option')
+ : { value: '', textContent: '' };
+ selectedOption.value = normalized;
+ selectedOption.textContent = normalized;
+ selectHeroSmsOperator.appendChild(selectedOption);
+ }
+ selectHeroSmsOperator.value = normalized;
+ return normalized;
}
function getFiveSimCountryLabelByCode(code = '') {
@@ -9351,6 +9586,7 @@ function updatePhoneVerificationSettingsUI() {
typeof rowHeroSmsCountry !== 'undefined' ? rowHeroSmsCountry : null,
typeof rowHeroSmsCountryFallback !== 'undefined' ? rowHeroSmsCountryFallback : null,
typeof rowHeroSmsAcquirePriority !== 'undefined' ? rowHeroSmsAcquirePriority : null,
+ typeof rowHeroSmsOperator !== 'undefined' ? rowHeroSmsOperator : null,
typeof rowHeroSmsApiKey !== 'undefined' ? rowHeroSmsApiKey : null,
typeof rowFiveSimApiKey !== 'undefined' ? rowFiveSimApiKey : null,
typeof rowFiveSimCountry !== 'undefined' ? rowFiveSimCountry : null,
@@ -9383,6 +9619,7 @@ function updatePhoneVerificationSettingsUI() {
if (rowHeroSmsCountry) rowHeroSmsCountry.style.display = showSettings && heroProvider ? '' : 'none';
if (rowHeroSmsCountryFallback) rowHeroSmsCountryFallback.style.display = showSettings && heroProvider ? '' : 'none';
if (rowHeroSmsAcquirePriority) rowHeroSmsAcquirePriority.style.display = showSettings && heroProvider ? '' : 'none';
+ if (typeof rowHeroSmsOperator !== 'undefined' && rowHeroSmsOperator) rowHeroSmsOperator.style.display = showSettings && heroProvider ? '' : 'none';
if (rowHeroSmsApiKey) rowHeroSmsApiKey.style.display = showSettings && heroProvider ? '' : 'none';
if (rowFiveSimApiKey) rowFiveSimApiKey.style.display = showSettings && fiveSimProvider ? '' : 'none';
if (rowFiveSimCountry) rowFiveSimCountry.style.display = showSettings && fiveSimProvider ? '' : 'none';
@@ -10844,6 +11081,7 @@ function applySettingsState(state) {
syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, {
activeFlowId: state?.activeFlowId || state?.flowId,
plusPaymentMethod: state?.plusPaymentMethod,
+ plusAccountAccessStrategy: stepDefinitionState.plusAccountAccessStrategy,
signupMethod: stepDefinitionState.signupMethod,
phoneSignupReloginAfterBindEmailEnabled: Boolean(state?.phoneSignupReloginAfterBindEmailEnabled),
accountContributionEnabled: Boolean(state?.accountContributionEnabled),
@@ -11348,6 +11586,9 @@ function applySettingsState(state) {
if (typeof selectHeroSmsAcquirePriority !== 'undefined' && selectHeroSmsAcquirePriority) {
selectHeroSmsAcquirePriority.value = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority);
}
+ if (typeof selectHeroSmsOperator !== 'undefined' && selectHeroSmsOperator) {
+ setHeroSmsOperatorSelectValue(state?.heroSmsOperator);
+ }
if (inputHeroSmsMaxPrice) {
inputHeroSmsMaxPrice.value = restoredPhoneSmsProvider === PHONE_SMS_PROVIDER_FIVE_SIM
? normalizeFiveSimMaxPriceValue(state?.fiveSimMaxPrice || '')
@@ -11486,11 +11727,6 @@ async function restoreState() {
displayLocalhostUrl.textContent = state.localhostUrl;
displayLocalhostUrl.classList.add('has-value');
}
- if (state.nodeStatuses) {
- for (const [nodeId, status] of Object.entries(state.nodeStatuses)) {
- updateNodeUI(nodeId, status);
- }
- }
if (state.logs) {
for (const entry of state.logs) {
@@ -13405,6 +13641,9 @@ function updatePanelModeUI() {
function updateNodeUI(nodeId, status) {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) return;
+ if (Array.isArray(NODE_IDS) && NODE_IDS.length && !NODE_IDS.includes(normalizedNodeId)) {
+ return;
+ }
syncLatestState({
nodeStatuses: {
...getStoredNodeStatuses(),
@@ -16536,6 +16775,7 @@ async function switchPhoneSmsProvider(nextProvider) {
patch.heroSmsCountryId = currentPrimary.id;
patch.heroSmsCountryLabel = currentPrimary.label;
patch.heroSmsCountryFallback = currentFallback;
+ patch.heroSmsOperator = normalizeHeroSmsOperatorValue(selectHeroSmsOperator?.value || latestState?.heroSmsOperator);
}
syncLatestState(patch);
@@ -16559,6 +16799,9 @@ async function switchPhoneSmsProvider(nextProvider) {
if (inputFiveSimOperator) {
inputFiveSimOperator.value = normalizeFiveSimOperator(latestState?.fiveSimOperator);
}
+ if (selectHeroSmsOperator) {
+ setHeroSmsOperatorSelectValue(latestState?.heroSmsOperator);
+ }
if (displayHeroSmsPriceTiers) displayHeroSmsPriceTiers.textContent = '未获取';
if (displayPhoneSmsBalance) displayPhoneSmsBalance.textContent = '余额未获取';
if (rowHeroSmsPriceTiers) rowHeroSmsPriceTiers.style.display = 'none';
@@ -16681,6 +16924,7 @@ selectPhoneSmsProvider?.addEventListener('change', async () => {
],
{ includePrimary: true }
);
+ setHeroSmsOperatorSelectValue(latestState?.heroSmsOperator);
}
updateHeroSmsPlatformDisplay();
updatePhoneVerificationSettingsUI();
@@ -16867,6 +17111,19 @@ selectHeroSmsAcquirePriority?.addEventListener('change', () => {
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
});
+selectHeroSmsOperator?.addEventListener('change', () => {
+ const nextOperator = normalizeHeroSmsOperatorValue(selectHeroSmsOperator.value);
+ selectHeroSmsOperator.value = nextOperator;
+ syncLatestState({ heroSmsOperator: nextOperator });
+ markSettingsDirty(true);
+ saveSettings({ silent: true }).catch(() => { });
+});
+selectHeroSmsOperator?.addEventListener('focus', () => {
+ refreshHeroSmsOperatorOptions({ silent: true });
+});
+selectHeroSmsOperator?.addEventListener('pointerdown', () => {
+ refreshHeroSmsOperatorOptions({ silent: true });
+});
selectHeroSmsPreferredActivation?.addEventListener('change', () => {
if (isPhoneSignupReuseLocked(latestState)) {
renderPhonePreferredActivationOptions(latestState);
@@ -17028,6 +17285,7 @@ selectHeroSmsCountry?.addEventListener('change', () => {
showLimitToast: true,
});
updateHeroSmsPlatformDisplay();
+ refreshHeroSmsOperatorOptions({ silent: true });
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
});
@@ -17039,6 +17297,7 @@ selectHeroSmsCountryFallback?.addEventListener('change', () => {
showLimitToast: true,
});
updateHeroSmsPlatformDisplay();
+ refreshHeroSmsOperatorOptions({ silent: true });
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
});
@@ -17063,6 +17322,7 @@ btnHeroSmsCountryClear?.addEventListener('click', () => {
showLimitToast: false,
});
updateHeroSmsPlatformDisplay();
+ refreshHeroSmsOperatorOptions({ silent: true });
setHeroSmsCountryMenuOpen(false);
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
@@ -17234,6 +17494,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
case 'AUTO_RUN_RESET': {
// Full UI reset for next run
syncLatestState({
+ runId: message.runId || message.payload?.runId || latestState?.runId || '',
+ activeRunId: message.activeRunId || message.payload?.activeRunId || latestState?.activeRunId || '',
oauthUrl: null,
localhostUrl: null,
email: null,
@@ -17289,6 +17551,14 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (isLuckmailProvider()) {
queueLuckmailPurchaseRefresh();
}
+ chrome.runtime.sendMessage({ type: 'GET_STATE' }).then((state) => {
+ if (!state) return;
+ syncLatestState(state);
+ applySettingsState(state);
+ updateStatusDisplay(state);
+ updateProgressCounter();
+ updateButtonStates();
+ }).catch(() => { });
break;
}
@@ -17642,6 +17912,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
setManagedAliasBaseEmailInputForProvider('2925', latestState);
}
}
+ if (message.payload.email !== undefined && inputEmail) {
+ inputEmail.value = message.payload.email || '';
+ }
if (message.payload.customEmailPoolEntries !== undefined || message.payload.customEmailPool !== undefined) {
setCustomEmailPoolEntriesState(restoreCustomEmailPoolEntriesFromState({
...latestState,
@@ -17650,6 +17923,12 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
syncRunCountFromConfiguredEmailPool();
queueCustomEmailPoolRefresh();
}
+ if (message.payload.customMailProviderPool !== undefined) {
+ if (inputCustomMailProviderPool) {
+ inputCustomMailProviderPool.value = normalizeCustomEmailPoolEntries(message.payload.customMailProviderPool).join('\n');
+ }
+ syncRunCountFromConfiguredEmailPool();
+ }
if (message.payload.luckmailApiKey !== undefined) {
inputLuckmailApiKey.value = message.payload.luckmailApiKey || '';
}
@@ -17805,6 +18084,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.payload.heroSmsAcquirePriority !== undefined && selectHeroSmsAcquirePriority) {
selectHeroSmsAcquirePriority.value = normalizeHeroSmsAcquirePriority(message.payload.heroSmsAcquirePriority);
}
+ if (message.payload.heroSmsOperator !== undefined && selectHeroSmsOperator) {
+ setHeroSmsOperatorSelectValue(message.payload.heroSmsOperator);
+ }
if ((message.payload.heroSmsMaxPrice !== undefined || message.payload.fiveSimMaxPrice !== undefined) && inputHeroSmsMaxPrice) {
inputHeroSmsMaxPrice.value = getSelectedPhoneSmsProvider() === PHONE_SMS_PROVIDER_FIVE_SIM
? normalizeFiveSimMaxPriceValue(message.payload.fiveSimMaxPrice !== undefined ? message.payload.fiveSimMaxPrice : latestState?.fiveSimMaxPrice)
diff --git a/start-custom-mail-helper.bat b/start-custom-mail-helper.bat
new file mode 100644
index 00000000..a168d61d
--- /dev/null
+++ b/start-custom-mail-helper.bat
@@ -0,0 +1,19 @@
+@echo off
+setlocal
+
+cd /d "%~dp0"
+
+where py >nul 2>nul
+if %errorlevel%==0 (
+ py -3 scripts\custom_mail_helper.py
+ goto :eof
+)
+
+where python >nul 2>nul
+if %errorlevel%==0 (
+ python scripts\custom_mail_helper.py
+ goto :eof
+)
+
+echo Python 3 not found. Please install Python 3.10+ and try again.
+pause
diff --git a/start-custom-mail-helper.command b/start-custom-mail-helper.command
new file mode 100644
index 00000000..61758a22
--- /dev/null
+++ b/start-custom-mail-helper.command
@@ -0,0 +1,16 @@
+#!/bin/bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+if command -v python3 >/dev/null 2>&1; then
+ exec python3 scripts/custom_mail_helper.py
+fi
+
+if command -v python >/dev/null 2>&1; then
+ exec python scripts/custom_mail_helper.py
+fi
+
+echo "Python 3 not found. Please install Python 3.10+ and try again."
+read -r -p "Press Enter to exit..."
diff --git a/tests/auto-run-fresh-attempt-reset.test.js b/tests/auto-run-fresh-attempt-reset.test.js
index 4505ae8d..29cfde59 100644
--- a/tests/auto-run-fresh-attempt-reset.test.js
+++ b/tests/auto-run-fresh-attempt-reset.test.js
@@ -64,7 +64,7 @@ const helperBundle = [
extractFunction(helperSource, 'getAutoRunStatusPayload'),
].join('\n');
-const api = new Function('autoRunModuleSource', `
+const api = new Function('autoRunModuleSource', 'assert', `
const self = {};
const STOP_ERROR_MESSAGE = 'Flow stopped.';
const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3;
@@ -142,6 +142,27 @@ let currentState = {
signupPhoneCompletedActivation: { activationId: 'signup-completed', phoneNumber: '+6612345' },
signupPhoneVerificationRequestedAt: 123456,
signupPhoneVerificationPurpose: 'signup',
+ email: 'old@example.com',
+ password: 'old-password',
+ registrationEmailState: {
+ current: 'old@example.com',
+ previous: 'old@example.com',
+ source: 'generated:old',
+ updatedAt: 123456,
+ },
+ oauthUrl: 'https://auth.example.com/old-oauth',
+ localhostUrl: 'http://localhost:1455/auth/callback?code=old&state=old-state',
+ sub2apiSessionId: 'old-sub2api-session',
+ sub2apiOAuthState: 'old-sub2api-state',
+ cpaOAuthState: 'old-cpa-state',
+ codex2apiSessionId: 'old-codex2api-session',
+ codex2apiOAuthState: 'old-codex2api-state',
+ currentCompletionTokenByNode: { 'fill-profile': 'old-token' },
+ seenCodes: ['old-code'],
+ seenInbucketMailIds: ['old-mail-id'],
+ loginVerificationRequestedAt: 234567,
+ oauthFlowDeadlineAt: 345678,
+ oauthFlowDeadlineSourceUrl: 'https://auth.example.com/old-oauth',
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: 'demo@gmail.com',
@@ -225,6 +246,9 @@ async function resetState() {
inbucketMailbox: prev.inbucketMailbox,
cloudflareDomain: prev.cloudflareDomain,
cloudflareDomains: [...(prev.cloudflareDomains || [])],
+ currentCompletionTokenByNode: prev.currentCompletionTokenByNode || {},
+ seenCodes: [...(prev.seenCodes || [])],
+ seenInbucketMailIds: [...(prev.seenInbucketMailIds || [])],
tabRegistry: { ...(prev.tabRegistry || {}) },
sourceLastUrls: { ...(prev.sourceLastUrls || {}) },
};
@@ -284,8 +308,41 @@ async function runAutoSequenceFromStep() {
throw new Error('fresh auto-run attempt reused stale runtime tab context');
}
+ if (runCalls === 2) {
+ assert.equal(state.email || '', '', 'fresh run must not reuse previous registration email');
+ assert.equal(state.registrationEmailState?.current || '', '', 'fresh run must clear registration email state');
+ assert.equal(state.oauthUrl || '', '', 'fresh run must not reuse previous OAuth URL');
+ assert.equal(state.localhostUrl || '', '', 'fresh run must not reuse previous localhost callback');
+ assert.equal(state.sub2apiSessionId || '', '', 'fresh run must not reuse previous SUB2API session');
+ assert.equal(state.cpaOAuthState || '', '', 'fresh run must not reuse previous CPA OAuth state');
+ assert.equal(state.codex2apiSessionId || '', '', 'fresh run must not reuse previous Codex2API session');
+ assert.deepStrictEqual(state.currentCompletionTokenByNode || {}, {}, 'fresh run must clear completion tokens');
+ assert.deepStrictEqual(state.seenCodes || [], [], 'fresh run must clear seen verification codes');
+ assert.deepStrictEqual(state.seenInbucketMailIds || [], [], 'fresh run must clear seen inbucket mail ids');
+ assert.equal(state.signupVerificationRequestedAt, null, 'fresh run must clear signup verification timestamp');
+ assert.equal(state.loginVerificationRequestedAt, null, 'fresh run must clear login verification timestamp');
+ assert.equal(state.oauthFlowDeadlineAt, null, 'fresh run must clear OAuth deadline');
+ assert.equal(state.oauthFlowDeadlineSourceUrl, null, 'fresh run must clear OAuth deadline source');
+ assert.equal(state.runId, '1001:2:1', 'fresh run should get an isolated run id');
+ assert.equal(state.activeRunId, '1001:2:1', 'fresh run should get an isolated active run id');
+ }
+
+ const nextEmail = 'run' + runCalls + '@example.com';
+
currentState = {
...currentState,
+ email: nextEmail,
+ registrationEmailState: {
+ current: nextEmail,
+ previous: nextEmail,
+ source: 'generated:test',
+ updatedAt: Date.now(),
+ },
+ oauthUrl: 'https://auth.example.com/oauth-' + runCalls,
+ localhostUrl: 'http://localhost:1455/auth/callback?code=code-' + runCalls + '&state=state-' + runCalls,
+ currentCompletionTokenByNode: { 'fill-profile': 'token-' + runCalls },
+ seenCodes: ['code-' + runCalls],
+ seenInbucketMailIds: ['mail-' + runCalls],
stepStatuses: {
1: 'completed',
2: 'completed',
@@ -408,7 +465,7 @@ return {
};
},
};
-`)(autoRunModuleSource);
+`)(autoRunModuleSource, assert);
(async () => {
await api.autoRunLoop(2, { autoRunSkipFailures: false, mode: 'restart' });
@@ -430,6 +487,8 @@ return {
assert.strictEqual(snapshot.currentState.signupPhoneCompletedActivation, null, 'completed signup phone activation should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneVerificationRequestedAt, null, 'signup phone request time should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneVerificationPurpose, '', 'signup phone purpose should be runtime-only');
+ assert.strictEqual(snapshot.currentState.runId, '1001:2:1', 'final state should keep the second round run id');
+ assert.strictEqual(snapshot.currentState.activeRunId, '1001:2:1', 'final state should keep the second round active run id');
assert.deepStrictEqual(
snapshot.currentState.reusablePhoneActivation,
{
diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js
index 21ed0f00..37634c8f 100644
--- a/tests/background-account-history-settings.test.js
+++ b/tests/background-account-history-settings.test.js
@@ -73,6 +73,7 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizePhoneCodePollMaxRounds'),
extractFunction('normalizeHeroSmsMaxPrice'),
extractFunction('normalizeHeroSmsCountryFallback'),
+ extractFunction('normalizeHeroSmsOperator'),
extractFunction('normalizePhoneSmsProvider'),
extractFunction('normalizeFiveSimCountryId'),
extractFunction('normalizeFiveSimCountryLabel'),
@@ -114,6 +115,7 @@ const VERIFICATION_RESEND_COUNT_MIN = 0;
const VERIFICATION_RESEND_COUNT_MAX = 20;
const HERO_SMS_COUNTRY_ID = 52;
const HERO_SMS_COUNTRY_LABEL = 'Thailand';
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
@@ -192,7 +194,9 @@ const PERSISTED_SETTING_DEFAULTS = {
mailProvider: '163',
heroSmsMinPrice: '',
fiveSimMinPrice: '',
+ heroSmsOperator: 'any',
};
+const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); }
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
@@ -312,6 +316,8 @@ return {
api.normalizePersistentSettingValue('heroSmsCountryFallback', [{ id: 16, label: 'United Kingdom' }, { id: 52 }]),
[{ id: 16, label: 'United Kingdom' }, { id: 52, label: 'Country #52' }]
);
+ assert.equal(api.normalizePersistentSettingValue('heroSmsOperator', ' AIS! '), 'ais');
+ assert.equal(api.normalizePersistentSettingValue('heroSmsOperator', ''), 'any');
assert.equal(
api.normalizePersistentSettingValue('accountRunHistoryHelperBaseUrl', 'http://127.0.0.1:17373/append-account-log'),
'http://127.0.0.1:17373'
@@ -373,9 +379,11 @@ return {
const rangePayload = api.buildPersistentSettingsPayload({
heroSmsMinPrice: '0.023456',
fiveSimMinPrice: '0.0789',
+ heroSmsOperator: ' AIS ',
});
assert.equal(rangePayload.heroSmsMinPrice, '0.0235');
assert.equal(rangePayload.fiveSimMinPrice, '0.0789');
+ assert.equal(rangePayload.heroSmsOperator, 'ais');
assert.deepStrictEqual(
api.normalizePersistentSettingValue('phonePreferredActivation', {
provider: 'nexsms',
diff --git a/tests/background-custom-email-pool.test.js b/tests/background-custom-email-pool.test.js
index b9844df6..5817843f 100644
--- a/tests/background-custom-email-pool.test.js
+++ b/tests/background-custom-email-pool.test.js
@@ -59,6 +59,7 @@ const bundle = [
extractFunction('getCustomEmailPoolEmailForRun'),
extractFunction('getCustomMailProviderPool'),
extractFunction('getCustomMailProviderPoolEmailForRun'),
+ extractFunction('removeCurrentCustomMailProviderPoolEmail'),
extractFunction('getEmailGeneratorLabel'),
].join('\n');
@@ -76,6 +77,7 @@ return {
getCustomEmailPoolEmailForRun,
getCustomMailProviderPool,
getCustomMailProviderPoolEmailForRun,
+ removeCurrentCustomMailProviderPoolEmail,
getEmailGeneratorLabel,
};
`)();
@@ -124,6 +126,68 @@ test('background selects the matching custom provider pool email for the current
assert.equal(api.getCustomMailProviderPoolEmailForRun(state, 4), '');
});
+test('auto email readiness checks custom provider pool before reusing stale current email', () => {
+ const ensureAutoEmailReadySource = extractFunction('ensureAutoEmailReady');
+ const staleEmailReuseIndex = ensureAutoEmailReadySource.indexOf('if (currentState.email)');
+ const customProviderPoolIndex = ensureAutoEmailReadySource.indexOf('if (isCustomMailProvider(currentState))');
+
+ assert.notEqual(staleEmailReuseIndex, -1);
+ assert.notEqual(customProviderPoolIndex, -1);
+ assert.equal(customProviderPoolIndex < staleEmailReuseIndex, true);
+});
+
+test('background removes successful custom provider pool email and keeps the next email first', async () => {
+ const persistentUpdates = [];
+ const stateUpdates = [];
+ const broadcasts = [];
+ const logs = [];
+ const api = new Function(`
+const CUSTOM_EMAIL_POOL_GENERATOR = 'custom-pool';
+const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email';
+const persistentUpdates = arguments[0];
+const stateUpdates = arguments[1];
+const broadcasts = arguments[2];
+const logs = arguments[3];
+async function setPersistentSettings(updates) { persistentUpdates.push(updates); }
+async function setState(updates) { stateUpdates.push(updates); }
+function broadcastDataUpdate(updates) { broadcasts.push(updates); }
+async function addLog(message, level) { logs.push({ message, level }); }
+async function getState() { return { email: 'first@example.com' }; }
+async function setEmailStateSilently(email) {
+ stateUpdates.push({ email });
+ broadcasts.push({ email });
+}
+
+${bundle}
+
+return { removeCurrentCustomMailProviderPoolEmail };
+`)(persistentUpdates, stateUpdates, broadcasts, logs);
+
+ const result = await api.removeCurrentCustomMailProviderPoolEmail({
+ mailProvider: 'custom',
+ email: 'first@example.com',
+ customMailProviderPool: ['first@example.com', 'second@example.com', 'third@example.com'],
+ }, {
+ logPrefix: '流程完成:自定义邮箱号池',
+ level: 'ok',
+ });
+
+ assert.equal(result.updated, true);
+ assert.deepEqual(result.customMailProviderPool, ['second@example.com', 'third@example.com']);
+ assert.equal(result.email, 'second@example.com');
+ assert.deepEqual(persistentUpdates.at(-1), {
+ customMailProviderPool: ['second@example.com', 'third@example.com'],
+ });
+ assert.deepEqual(stateUpdates.at(-1), {
+ customMailProviderPool: ['second@example.com', 'third@example.com'],
+ });
+ assert.deepEqual(broadcasts.at(-1), {
+ customMailProviderPool: ['second@example.com', 'third@example.com'],
+ email: 'second@example.com',
+ });
+ assert.equal(logs.some((entry) => /已从号池删除 first@example\.com,下轮将使用 second@example\.com/.test(entry.message)), true);
+});
+
test('background derives active custom email pool from structured entries', () => {
const api = createApi();
const state = {
diff --git a/tests/background-operation-delay-exclusions.test.js b/tests/background-operation-delay-exclusions.test.js
index d73fb853..12c8a176 100644
--- a/tests/background-operation-delay-exclusions.test.js
+++ b/tests/background-operation-delay-exclusions.test.js
@@ -20,3 +20,16 @@ test('operation delay gate names exactly the two excluded step keys', () => {
assert.match(source, /confirm-oauth/);
assert.match(source, /platform-verify/);
});
+
+test('platform-verify stays background-completed instead of signal-waited', () => {
+ const source = fs.readFileSync('background.js', 'utf8');
+ const backgroundCompletedSet = source.match(/AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS\s*=\s*new Set\(\[([\s\S]*?)\]\);/)?.[1] || '';
+ const completionSignalSet = source.match(/STEP_COMPLETION_SIGNAL_STEP_KEYS\s*=\s*new Set\(\[([\s\S]*?)\]\);/)?.[1] || '';
+
+ assert.match(backgroundCompletedSet, /'platform-verify'/, 'platform-verify should be completed by its background executor');
+ assert.doesNotMatch(
+ completionSignalSet,
+ /'platform-verify'/,
+ 'platform-verify must not wait for a completion signal because background completion does not carry the generated token'
+ );
+});
diff --git a/tests/background-registration-account-used.test.js b/tests/background-registration-account-used.test.js
index 96833f5e..c5ca50aa 100644
--- a/tests/background-registration-account-used.test.js
+++ b/tests/background-registration-account-used.test.js
@@ -104,3 +104,61 @@ return { markCurrentRegistrationAccountUsed, patchCalls, logs };
assert.equal(api.patchCalls[0].updates.used, true);
assert.equal(api.logs.some((entry) => /Hotmail 账号已标记为已用/.test(entry.message)), true);
});
+
+test('markCurrentRegistrationAccountUsed removes successful custom mail provider pool email', async () => {
+ const bundle = extractFunction('markCurrentRegistrationAccountUsed');
+ const factory = new Function(`
+const removedCalls = [];
+const logs = [];
+async function getState() {
+ return {
+ mailProvider: 'custom',
+ email: 'first@example.com',
+ customMailProviderPool: ['first@example.com', 'second@example.com'],
+ };
+}
+function isHotmailProvider() {
+ return false;
+}
+function isLuckmailProvider() {
+ return false;
+}
+function getCurrentLuckmailPurchase() {
+ return null;
+}
+async function patchHotmailAccount() {}
+async function setLuckmailPurchaseUsedState() {}
+async function clearLuckmailRuntimeState() {}
+async function patchMail2925Account() {}
+async function finalizeIcloudAliasAfterSuccessfulFlow() {
+ return { handled: false };
+}
+async function markCurrentCustomEmailPoolEntryUsed() {
+ return { updated: false };
+}
+async function removeCurrentCustomMailProviderPoolEmail(state, options) {
+ removedCalls.push({ state, options });
+ return { updated: true, customMailProviderPool: ['second@example.com'] };
+}
+async function addLog(message, level) {
+ logs.push({ message, level });
+}
+
+${bundle}
+
+return { markCurrentRegistrationAccountUsed, removedCalls, logs };
+`);
+ const api = factory();
+
+ const result = await api.markCurrentRegistrationAccountUsed({ email: 'stale@example.com' }, {
+ logPrefix: '流程完成',
+ level: 'ok',
+ });
+
+ assert.equal(result.updated, true);
+ assert.equal(api.removedCalls.length, 1);
+ assert.equal(api.removedCalls[0].state.email, 'first@example.com');
+ assert.deepEqual(api.removedCalls[0].state.customMailProviderPool, ['first@example.com', 'second@example.com']);
+ assert.equal(api.removedCalls[0].options.logPrefix, '流程完成:自定义邮箱号池');
+ assert.equal(api.removedCalls[0].options.level, 'ok');
+});
diff --git a/tests/background-signup-step2-branching.test.js b/tests/background-signup-step2-branching.test.js
index 3090fbf7..f4674710 100644
--- a/tests/background-signup-step2-branching.test.js
+++ b/tests/background-signup-step2-branching.test.js
@@ -636,6 +636,37 @@ test('signup flow helper waits for the signup entry tab to settle for step 2 bef
});
});
+test('openSignupEntryTab forces a fresh auth tab for step 1 but can reuse for later steps', async () => {
+ const calls = [];
+ const helpers = signupFlowApi.createSignupFlowHelpers({
+ addLog: async () => {},
+ chrome: {},
+ ensureContentScriptReadyOnTab: async () => {},
+ ensureHotmailAccountForFlow: async () => ({}),
+ ensureLuckmailPurchaseForFlow: async () => ({}),
+ isGeneratedAliasProvider: () => false,
+ isHotmailProvider: () => false,
+ isLuckmailProvider: () => false,
+ isSignupEmailVerificationPageUrl: () => false,
+ isSignupPasswordPageUrl: () => false,
+ reuseOrCreateTab: async (_source, _url, options) => {
+ calls.push({ ...options });
+ return calls.length;
+ },
+ sendToContentScriptResilient: async () => ({ ready: true }),
+ setEmailState: async () => {},
+ SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
+ OPENAI_AUTH_INJECT_FILES: ['flows/openai/content/openai-auth.js'],
+ waitForTabUrlMatch: async () => null,
+ });
+
+ await helpers.openSignupEntryTab(1);
+ await helpers.openSignupEntryTab(2);
+
+ assert.equal(calls[0].forceNew, true);
+ assert.equal(calls[1].forceNew, false);
+});
+
test('signup flow helper accepts phone signup landing on login password page', async () => {
let ensureCalls = 0;
let passwordReadyChecks = 0;
diff --git a/tests/background-step-execution-range.test.js b/tests/background-step-execution-range.test.js
index daa69224..785fd5aa 100644
--- a/tests/background-step-execution-range.test.js
+++ b/tests/background-step-execution-range.test.js
@@ -162,3 +162,66 @@ test('step execution range ignores progress outside the allowed range', () => {
assert.equal(api.hasSavedNodeProgress(state.nodeStatuses, state), true);
assert.equal(api.getFirstUnfinishedNodeId(state.nodeStatuses, state), 'fetch-signup-code');
});
+
+test('setNodeStatus advances current node to step 6 after fill-profile completes', async () => {
+ const events = [];
+ const api = new Function('events', `
+let state = {
+ activeFlowId: 'openai',
+ nodeStatuses: {
+ 'open-chatgpt': 'completed',
+ 'submit-signup-email': 'completed',
+ 'fill-password': 'completed',
+ 'fetch-signup-code': 'completed',
+ 'fill-profile': 'running',
+ 'wait-registration-success': 'pending',
+ },
+ currentNodeId: 'fill-profile',
+};
+const DEFAULT_ACTIVE_FLOW_ID = 'openai';
+const DEFAULT_STATE = { nodeStatuses: ${JSON.stringify(Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, 'pending'])))} };
+const chrome = {
+ runtime: {
+ sendMessage(message) {
+ events.push({ type: 'message', message });
+ return { catch() {} };
+ },
+ },
+};
+async function getState() { return state; }
+async function setState(updates) {
+ events.push({ type: 'setState', updates });
+ state = { ...state, ...updates };
+}
+function getNodeIdsForState() { return ${JSON.stringify(NODE_IDS)}; }
+function getStepIdByNodeIdForState(nodeId) { return ${JSON.stringify(NODE_STEPS)}[String(nodeId || '').trim()] || 0; }
+function getNodeIdByStepForState(step) { return ${JSON.stringify(Object.fromEntries(Object.entries(NODE_STEPS).map(([nodeId, step]) => [step, nodeId])))}[Number(step)] || ''; }
+${[
+ 'isPlainObjectValue',
+ 'normalizeStepExecutionRangeFlowId',
+ 'hasStepExecutionRangeShape',
+ 'normalizePositiveStepNumber',
+ 'normalizeStepExecutionRangeEntry',
+ 'normalizeStepExecutionRangeByFlow',
+ 'getStepExecutionRangeForState',
+ 'isStepAllowedByExecutionRangeForState',
+ 'isNodeExecutionAllowedForState',
+ 'getExecutionAllowedNodeIdsForState',
+ 'isStepDoneStatus',
+ 'normalizeStatusMapForNodes',
+ 'getFirstUnfinishedNodeId',
+ 'setNodeStatus',
+].map(extractFunction).join('\n')}
+return {
+ async run() {
+ await setNodeStatus('fill-profile', 'completed');
+ return state;
+ },
+};
+`)(events);
+
+ const nextState = await api.run();
+
+ assert.equal(nextState.nodeStatuses['fill-profile'], 'completed');
+ assert.equal(nextState.currentNodeId, 'wait-registration-success');
+});
diff --git a/tests/background-step4-filter-window.test.js b/tests/background-step4-filter-window.test.js
index e9e3a89f..3429b224 100644
--- a/tests/background-step4-filter-window.test.js
+++ b/tests/background-step4-filter-window.test.js
@@ -6,6 +6,42 @@ const source = fs.readFileSync('flows/openai/background/steps/fetch-signup-code.
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep4;`)(globalScope);
+test('step 4 routes custom mail provider through resolver instead of manual confirmation', async () => {
+ let bypassCalls = 0;
+ let capturedMail = null;
+ let capturedOptions = null;
+ const executor = api.createStep4Executor({
+ addLog: async () => {},
+ chrome: { tabs: { update: async () => {} } },
+ completeNodeFromBackground: async () => {},
+ confirmCustomVerificationStepBypass: async () => { bypassCalls += 1; },
+ getMailConfig: () => ({ provider: 'custom', label: '自定义邮箱' }),
+ getTabId: async () => 1,
+ HOTMAIL_PROVIDER: 'hotmail-api',
+ isTabAlive: async () => false,
+ LUCKMAIL_PROVIDER: 'luckmail-api',
+ CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+ CLOUD_MAIL_PROVIDER: 'cloudmail',
+ resolveVerificationStep: async (_step, _state, mail, options) => {
+ capturedMail = mail;
+ capturedOptions = options;
+ },
+ reuseOrCreateTab: async () => {},
+ sendToContentScript: async () => ({}),
+ sendToContentScriptResilient: async () => ({}),
+ isRetryableContentScriptTransportError: () => false,
+ shouldUseCustomRegistrationEmail: () => true,
+ STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
+ throwIfStopped: () => {},
+ });
+
+ await executor.executeStep4({ email: 'target@example.com', mailProvider: 'custom' });
+
+ assert.equal(bypassCalls, 0);
+ assert.equal(capturedMail.provider, 'custom');
+ assert.equal(capturedOptions.requestFreshCodeFirst, false);
+});
+
test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling', async () => {
let capturedOptions = null;
let ensureCalls = 0;
diff --git a/tests/background-step5-completion-signal-fallback.test.js b/tests/background-step5-completion-signal-fallback.test.js
new file mode 100644
index 00000000..ed5e84ad
--- /dev/null
+++ b/tests/background-step5-completion-signal-fallback.test.js
@@ -0,0 +1,526 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background.js', 'utf8');
+
+function extractFunction(name) {
+ const markers = [`async function ${name}(`, `function ${name}(`];
+ const start = markers
+ .map((marker) => source.indexOf(marker))
+ .find((index) => index >= 0);
+ if (start < 0) {
+ throw new Error(`missing function ${name}`);
+ }
+
+ let parenDepth = 0;
+ let signatureEnded = false;
+ let braceStart = -1;
+ for (let i = start; i < source.length; i += 1) {
+ const ch = source[i];
+ if (ch === '(') {
+ parenDepth += 1;
+ } else if (ch === ')') {
+ parenDepth -= 1;
+ if (parenDepth === 0) {
+ signatureEnded = true;
+ }
+ } else if (ch === '{' && signatureEnded) {
+ braceStart = i;
+ break;
+ }
+ }
+
+ if (braceStart < 0) {
+ throw new Error(`missing body for function ${name}`);
+ }
+
+ let depth = 0;
+ let end = braceStart;
+ for (; end < source.length; end += 1) {
+ const ch = source[end];
+ if (ch === '{') depth += 1;
+ if (ch === '}') {
+ depth -= 1;
+ if (depth === 0) {
+ end += 1;
+ break;
+ }
+ }
+ }
+
+ return source.slice(start, end);
+}
+
+test('executeNodeViaCompletionSignal lets fill-profile succeed through background recovery after retryable transport error', async () => {
+ const api = new Function(`
+const events = [];
+const LOG_PREFIX = '[test]';
+const nodeWaiters = new Map();
+
+function getErrorMessage(error) {
+ return error?.message || String(error || '');
+}
+
+async function getState() {
+ return {};
+}
+
+async function setState(updates) {
+ events.push({ type: 'setState', updates });
+}
+
+function createNodeCompletionToken(nodeId) {
+ return String(nodeId || 'node').trim() + ':token';
+}
+
+function assertNodeExecutionAllowedForState() {}
+
+function getNodeCompletionSignalTimeoutMs() {
+ return 12345;
+}
+
+function waitForNodeComplete(nodeId) {
+ nodeWaiters.set(nodeId, {});
+ return new Promise((_, reject) => {
+ setTimeout(() => reject(new Error('节点 fill-profile 等待超时(>12 秒)')), 5);
+ });
+}
+
+async function executeNode(nodeId, options) {
+ events.push({ type: 'executeNode', nodeId, options });
+ throw new Error('The page keeping the extension port is moved into back/forward cache, so the message channel is closed.');
+}
+
+function isStopError() {
+ return false;
+}
+
+function isRetryableContentScriptTransportError(error) {
+ return /back\\/forward cache|message channel is closed/i.test(String(error?.message || error || ''));
+}
+
+function notifyNodeError(nodeId, error) {
+ events.push({ type: 'notifyNodeError', nodeId, error });
+}
+
+async function recoverFillProfileCompletionFromBackground(error, completionToken) {
+ events.push({ type: 'backgroundRecovery', error: getErrorMessage(error), completionToken });
+ return {
+ nodeId: 'fill-profile',
+ completionToken,
+ outcome: 'background_transport_recovered',
+ successState: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ recoveredByBackground: true,
+ };
+}
+
+async function finalizeDeferredNodeExecutionError(nodeId, error) {
+ events.push({ type: 'finalizeDeferredNodeExecutionError', nodeId, error: getErrorMessage(error) });
+}
+
+${extractFunction('executeNodeViaCompletionSignal')}
+
+return {
+ run() {
+ return executeNodeViaCompletionSignal('fill-profile');
+ },
+ snapshot() {
+ return events;
+ },
+};
+`)();
+
+ const result = await api.run();
+ const events = api.snapshot();
+
+ assert.equal(result.recoveredByBackground, true);
+ assert.equal(result.url, 'https://chatgpt.com/');
+ assert.equal(events.some((entry) => entry.type === 'backgroundRecovery'), true);
+ assert.equal(events.find((entry) => entry.type === 'executeNode').options.completionToken, 'fill-profile:token');
+ assert.equal(events.find((entry) => entry.type === 'backgroundRecovery').completionToken, 'fill-profile:token');
+ assert.equal(events.some((entry) => entry.type === 'notifyNodeError'), false);
+ assert.equal(events.some((entry) => entry.type === 'finalizeDeferredNodeExecutionError'), false);
+});
+
+test('notifyNodeComplete ignores stale fill-profile completion token from previous auto-run round', async () => {
+ const api = new Function(`
+const events = [];
+const LOG_PREFIX = '[test]';
+const nodeWaiters = new Map();
+
+${extractFunction('notifyNodeComplete')}
+
+return {
+ run() {
+ const resolved = [];
+ nodeWaiters.set('fill-profile', {
+ completionToken: 'round-2-token',
+ resolve(payload) {
+ resolved.push(payload);
+ },
+ });
+ notifyNodeComplete('fill-profile', { completionToken: 'round-1-token', url: 'https://old.example/' });
+ notifyNodeComplete('fill-profile', { completionToken: 'round-2-token', url: 'https://chatgpt.com/' });
+ return resolved;
+ },
+};
+`)();
+
+ const resolved = api.run();
+
+ assert.deepStrictEqual(resolved, [
+ { completionToken: 'round-2-token', url: 'https://chatgpt.com/' },
+ ]);
+});
+
+test('executeNodeViaCompletionSignal keeps non-fill-profile retryable transport errors on original path', async () => {
+ const api = new Function(`
+const events = [];
+const LOG_PREFIX = '[test]';
+
+function getErrorMessage(error) {
+ return error?.message || String(error || '');
+}
+
+async function getState() {
+ return {};
+}
+
+async function setState(updates) {
+ events.push({ type: 'setState', updates });
+}
+
+function createNodeCompletionToken(nodeId) {
+ return String(nodeId || 'node').trim() + ':token';
+}
+
+function assertNodeExecutionAllowedForState() {}
+
+function getNodeCompletionSignalTimeoutMs() {
+ return 12345;
+}
+
+const nodeWaiters = new Map();
+
+function waitForNodeComplete(nodeId) {
+ nodeWaiters.set(nodeId, {});
+ return new Promise((_, reject) => {
+ setTimeout(() => reject(new Error('节点 oauth-login 等待超时(>12 秒)')), 5);
+ });
+}
+
+async function executeNode(nodeId, options) {
+ events.push({ type: 'executeNode', nodeId, options });
+ throw new Error('The page keeping the extension port is moved into back/forward cache, so the message channel is closed.');
+}
+
+function isStopError() {
+ return false;
+}
+
+function isRetryableContentScriptTransportError(error) {
+ return /back\\/forward cache|message channel is closed/i.test(String(error?.message || error || ''));
+}
+
+function notifyNodeError(nodeId, error) {
+ events.push({ type: 'notifyNodeError', nodeId, error });
+}
+
+async function recoverFillProfileCompletionFromBackground(error) {
+ events.push({ type: 'backgroundRecovery', error: getErrorMessage(error) });
+ return {};
+}
+
+async function finalizeDeferredNodeExecutionError(nodeId, error) {
+ events.push({ type: 'finalizeDeferredNodeExecutionError', nodeId, error: getErrorMessage(error) });
+}
+
+${extractFunction('executeNodeViaCompletionSignal')}
+
+return {
+ run() {
+ return executeNodeViaCompletionSignal('oauth-login');
+ },
+ snapshot() {
+ return events;
+ },
+};
+`)();
+
+ await assert.rejects(
+ api.run(),
+ /message channel is closed/
+ );
+ const events = api.snapshot();
+ assert.equal(events.some((entry) => entry.type === 'backgroundRecovery'), false);
+ assert.equal(events.some((entry) => entry.type === 'finalizeDeferredNodeExecutionError'), true);
+});
+
+test('step 5 background recovery clears prompts and converts retryable transport error into success', async () => {
+ const api = new Function(`
+const logs = [];
+const completions = [];
+const waitCalls = [];
+const messages = [];
+let promptAdvanceCount = 0;
+
+function getErrorMessage(error) {
+ return error?.message || String(error || '');
+}
+
+async function addLog(message, level, meta) {
+ logs.push({ message, level, meta });
+}
+
+async function getTabId(source) {
+ return source === 'openai-auth' ? 99 : null;
+}
+
+async function waitForTabStableComplete(tabId, options) {
+ waitCalls.push({ tabId, options });
+}
+
+function notifyNodeComplete(nodeId, payload) {
+ completions.push({ nodeId, payload });
+}
+
+const chrome = {
+ tabs: {
+ async get() {
+ return { url: 'https://chatgpt.com/' };
+ },
+ },
+};
+
+async function sendToContentScriptResilient(source, message) {
+ messages.push({ source, type: message.type });
+ if (message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT') {
+ promptAdvanceCount += 1;
+ return {
+ advanced: promptAdvanceCount <= 2,
+ reason: promptAdvanceCount > 2 ? 'prompt_not_detected' : '',
+ state: { url: 'https://chatgpt.com/' },
+ };
+ }
+ if (message.type === 'GET_STEP5_SUBMIT_STATE') {
+ return {
+ retryPage: false,
+ retryEnabled: false,
+ maxCheckAttemptsBlocked: false,
+ userAlreadyExistsBlocked: false,
+ successState: 'logged_in_home',
+ profileVisible: false,
+ errorText: '',
+ unknownAuthPage: false,
+ url: 'https://chatgpt.com/',
+ };
+ }
+ throw new Error('unexpected message type: ' + message.type);
+}
+
+${extractFunction('parseUrlSafely')}
+${extractFunction('isSignupEntryHost')}
+${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
+${extractFunction('isStep5CompletionChatgptUrl')}
+${extractFunction('advanceStep5PostSubmitPromptOnTab')}
+${extractFunction('getStep5SubmitStateFromContent')}
+${extractFunction('recoverStep5SubmitRetryPageOnTab')}
+${extractFunction('validateStep5PostCompletion')}
+${extractFunction('recoverFillProfileCompletionFromBackground')}
+
+return {
+ async run() {
+ return recoverFillProfileCompletionFromBackground(
+ new Error('The page keeping the extension port is moved into back/forward cache, so the message channel is closed.')
+ );
+ },
+ snapshot() {
+ return { logs, completions, waitCalls, messages, promptAdvanceCount };
+ },
+};
+`)();
+
+ const result = await api.run();
+ const snapshot = api.snapshot();
+
+ assert.equal(result.successState, 'logged_in_home');
+ assert.equal(result.recoveredByBackground, true);
+ assert.equal(result.step5PostCompletionValidated, true);
+ assert.equal(result.url, 'https://chatgpt.com/');
+ assert.equal(result.requireContentStateBeforeUrlSuccess, true);
+ assert.equal(result.postSubmitPromptActionsCompleted, true);
+ assert.equal(result.postSubmitPromptActionCount, 2);
+ assert.equal(snapshot.promptAdvanceCount, 3);
+ assert.equal(snapshot.waitCalls.length, 3);
+ assert.deepStrictEqual(snapshot.completions, [
+ {
+ nodeId: 'fill-profile',
+ payload: result,
+ },
+ ]);
+ assert.deepStrictEqual(
+ snapshot.messages.map(({ type }) => type),
+ [
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ ]
+ );
+ assert.equal(
+ snapshot.logs.some(({ message }) => /页面通信中断,正在通过后台复核最终状态/.test(message)),
+ true
+ );
+});
+
+test('executeNodeAndWait skips duplicate step 5 validation after background recovery already validated completion', async () => {
+ const api = new Function(`
+const events = [];
+
+function throwIfStopped() {}
+
+async function getState() {
+ return {
+ autoStepDelaySeconds: 0,
+ nodeStatuses: {
+ 'fill-profile': 'running',
+ 'wait-registration-success': 'pending',
+ },
+ };
+}
+
+function assertNodeExecutionAllowedForState() {}
+function normalizeAutoStepDelaySeconds() { return 0; }
+async function addLog(message, level, meta) { events.push({ type: 'log', message, level, meta }); }
+async function sleepWithStop() {}
+function getStepIdByNodeIdForState(nodeId) { return nodeId === 'fill-profile' ? 5 : 0; }
+function getAutoRunPreExecutionDelayMsForNode() { return 0; }
+function doesNodeUseBackgroundCompletion() { return false; }
+function doesNodeUseCompletionSignal(nodeId) { return nodeId === 'fill-profile'; }
+function getNodeCompletionSignalTimeoutMs() { return 150000; }
+async function executeNode() { throw new Error('executeNode should not be called directly'); }
+async function executeNodeViaCompletionSignal(nodeId, timeoutMs) {
+ events.push({ type: 'completionSignal', nodeId, timeoutMs });
+ return {
+ nodeId,
+ outcome: 'background_transport_recovered',
+ successState: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ recoveredByBackground: true,
+ step5PostCompletionValidated: true,
+ };
+}
+async function getTabId(source) { return source === 'openai-auth' ? 99 : null; }
+async function waitForTabStableComplete() { events.push({ type: 'waitForTabStableComplete' }); }
+async function validateStep5PostCompletion() { throw new Error('duplicate validation should be skipped'); }
+async function setNodeStatus(nodeId, status) { events.push({ type: 'status', nodeId, status }); }
+function getErrorMessage(error) { return error?.message || String(error || ''); }
+
+${extractFunction('executeNodeAndWait')}
+
+return {
+ async run() {
+ await executeNodeAndWait('fill-profile', 0);
+ },
+ snapshot() {
+ return events;
+ },
+};
+`)();
+
+ await api.run();
+ const events = api.snapshot();
+
+ assert.equal(events.some((entry) => entry.type === 'waitForTabStableComplete'), false);
+ assert.equal(
+ events.some((entry) => entry.type === 'log' && /后台恢复已完成最终复核,直接进入后续节点/.test(entry.message)),
+ true
+ );
+ assert.equal(
+ events.some((entry) => entry.type === 'status' && entry.nodeId === 'fill-profile' && entry.status === 'completed'),
+ true
+ );
+});
+
+test('step 5 background recovery still fails when page remains on profile after retryable transport error', async () => {
+ const api = new Function(`
+const logs = [];
+
+function getErrorMessage(error) {
+ return error?.message || String(error || '');
+}
+
+async function addLog(message, level, meta) {
+ logs.push({ message, level, meta });
+}
+
+async function getTabId(source) {
+ return source === 'openai-auth' ? 99 : null;
+}
+
+async function waitForTabStableComplete() {}
+
+function notifyNodeComplete() {
+ throw new Error('should not notify completion');
+}
+
+const chrome = {
+ tabs: {
+ async get() {
+ return { url: 'https://auth.openai.com/about-you' };
+ },
+ },
+};
+
+async function sendToContentScriptResilient(source, message) {
+ if (message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT') {
+ return { advanced: false };
+ }
+ if (message.type === 'GET_STEP5_SUBMIT_STATE') {
+ return {
+ retryPage: false,
+ retryEnabled: false,
+ maxCheckAttemptsBlocked: false,
+ userAlreadyExistsBlocked: false,
+ successState: '',
+ profileVisible: true,
+ errorText: '',
+ unknownAuthPage: false,
+ url: 'https://auth.openai.com/about-you',
+ };
+ }
+ throw new Error('unexpected message type: ' + message.type);
+}
+
+${extractFunction('parseUrlSafely')}
+${extractFunction('isSignupEntryHost')}
+${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
+${extractFunction('isStep5CompletionChatgptUrl')}
+${extractFunction('advanceStep5PostSubmitPromptOnTab')}
+${extractFunction('getStep5SubmitStateFromContent')}
+${extractFunction('recoverStep5SubmitRetryPageOnTab')}
+${extractFunction('validateStep5PostCompletion')}
+${extractFunction('recoverFillProfileCompletionFromBackground')}
+
+return {
+ run() {
+ return recoverFillProfileCompletionFromBackground(
+ new Error('The page keeping the extension port is moved into back/forward cache, so the message channel is closed.')
+ );
+ },
+ snapshot() {
+ return { logs };
+ },
+};
+`)();
+
+ await assert.rejects(
+ api.run(),
+ /资料提交完成信号已收到,但页面仍停留在资料页/
+ );
+ assert.equal(
+ api.snapshot().logs.some(({ message }) => /页面通信中断,正在通过后台复核最终状态/.test(message)),
+ true
+ );
+});
diff --git a/tests/background-step5-post-completion-validation.test.js b/tests/background-step5-post-completion-validation.test.js
index 690e8a0d..16323996 100644
--- a/tests/background-step5-post-completion-validation.test.js
+++ b/tests/background-step5-post-completion-validation.test.js
@@ -68,6 +68,9 @@ const chrome = {
async function sendToContentScriptResilient(source, message) {
messages.push({ source, type: message.type });
+ if (message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT') {
+ return { advanced: false };
+ }
if (message.type === 'GET_STEP5_SUBMIT_STATE') {
stateReadCount += 1;
if (stateReadCount === 1) {
@@ -105,12 +108,17 @@ async function addLog(message, level, meta) {
logs.push({ message, level, meta });
}
+function getErrorMessage(error) {
+ return error?.message || String(error || '');
+}
+
async function waitForTabStableComplete() {}
${extractFunction('parseUrlSafely')}
${extractFunction('isSignupEntryHost')}
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
${extractFunction('isStep5CompletionChatgptUrl')}
+${extractFunction('advanceStep5PostSubmitPromptOnTab')}
${extractFunction('getStep5SubmitStateFromContent')}
${extractFunction('recoverStep5SubmitRetryPageOnTab')}
${extractFunction('validateStep5PostCompletion')}
@@ -131,7 +139,7 @@ return {
assert.equal(result.successState, 'logged_in_home');
assert.deepStrictEqual(
snapshot.messages.map(({ type }) => type),
- ['GET_STEP5_SUBMIT_STATE', 'RECOVER_STEP5_SUBMIT_RETRY_PAGE', 'GET_STEP5_SUBMIT_STATE']
+ ['ADVANCE_STEP5_POST_SUBMIT_PROMPT', 'GET_STEP5_SUBMIT_STATE', 'RECOVER_STEP5_SUBMIT_RETRY_PAGE', 'ADVANCE_STEP5_POST_SUBMIT_PROMPT', 'GET_STEP5_SUBMIT_STATE']
);
assert.equal(snapshot.stateReadCount, 2);
assert.equal(
@@ -152,6 +160,9 @@ const chrome = {
};
async function sendToContentScriptResilient(source, message) {
+ if (message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT') {
+ return { advanced: false };
+ }
if (message.type === 'GET_STEP5_SUBMIT_STATE') {
return {
retryPage: false,
@@ -172,12 +183,17 @@ async function addLog(message, level, meta) {
logs.push({ message, level, meta });
}
+function getErrorMessage(error) {
+ return error?.message || String(error || '');
+}
+
async function waitForTabStableComplete() {}
${extractFunction('parseUrlSafely')}
${extractFunction('isSignupEntryHost')}
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
${extractFunction('isStep5CompletionChatgptUrl')}
+${extractFunction('advanceStep5PostSubmitPromptOnTab')}
${extractFunction('getStep5SubmitStateFromContent')}
${extractFunction('recoverStep5SubmitRetryPageOnTab')}
${extractFunction('validateStep5PostCompletion')}
@@ -201,3 +217,231 @@ return {
true
);
});
+
+test('step 5 post-completion validation completes after final prompt miss following two successful actions', async () => {
+ const api = new Function(`
+const messages = [];
+let promptAdvanceCount = 0;
+let stateReadCount = 0;
+const chrome = {
+ tabs: {
+ async get() {
+ return { url: 'https://chatgpt.com/' };
+ },
+ },
+};
+
+async function sendToContentScriptResilient(source, message) {
+ messages.push({ source, type: message.type });
+ if (message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT') {
+ promptAdvanceCount += 1;
+ return { advanced: promptAdvanceCount <= 2, actionText: promptAdvanceCount === 1 ? '跳过' : '继续' };
+ }
+ if (message.type === 'GET_STEP5_SUBMIT_STATE') {
+ stateReadCount += 1;
+ return {
+ retryPage: false,
+ retryEnabled: false,
+ maxCheckAttemptsBlocked: false,
+ userAlreadyExistsBlocked: false,
+ successState: 'logged_in_home',
+ profileVisible: false,
+ errorText: '',
+ unknownAuthPage: false,
+ url: 'https://chatgpt.com/',
+ };
+ }
+ throw new Error('unexpected message type: ' + message.type);
+}
+
+async function addLog() {}
+function getErrorMessage(error) { return error?.message || String(error || ''); }
+async function waitForTabStableComplete() {}
+
+${extractFunction('parseUrlSafely')}
+${extractFunction('isSignupEntryHost')}
+${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
+${extractFunction('isStep5CompletionChatgptUrl')}
+${extractFunction('advanceStep5PostSubmitPromptOnTab')}
+${extractFunction('getStep5SubmitStateFromContent')}
+${extractFunction('recoverStep5SubmitRetryPageOnTab')}
+${extractFunction('validateStep5PostCompletion')}
+
+return {
+ async run() {
+ return validateStep5PostCompletion(99, { maxPostSubmitPromptActions: 4 });
+ },
+ snapshot() {
+ return { messages, promptAdvanceCount, stateReadCount };
+ },
+};
+`)();
+
+ const result = await api.run();
+ const snapshot = api.snapshot();
+
+ assert.equal(result.successState, 'logged_in_home');
+ assert.equal(result.postSubmitPromptActionsCompleted, true);
+ assert.equal(result.postSubmitPromptActionCount, 2);
+ assert.deepStrictEqual(
+ snapshot.messages.map(({ type }) => type),
+ [
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ ]
+ );
+ assert.equal(snapshot.promptAdvanceCount, 3);
+ assert.equal(snapshot.stateReadCount, 0);
+});
+
+test('step 5 post-completion validation falls back to direct button click when content prompt command stalls', async () => {
+ const api = new Function(`
+const messages = [];
+const logs = [];
+const fallbackClicks = [];
+let fallbackRound = 0;
+const chrome = {
+ tabs: {
+ async get() {
+ return { url: 'https://chatgpt.com/' };
+ },
+ },
+ scripting: {
+ async executeScript(details) {
+ fallbackRound += 1;
+ const result = fallbackRound === 1
+ ? { advanced: true, actionText: '跳过', fallback: true }
+ : fallbackRound === 2
+ ? { advanced: true, actionText: '继续', fallback: true }
+ : { advanced: false, fallback: true, reason: 'prompt_not_detected' };
+ if (result.advanced) {
+ fallbackClicks.push(result.actionText);
+ }
+ return [{ result }];
+ },
+ },
+};
+
+async function sendToContentScriptResilient(source, message) {
+ messages.push({ source, type: message.type });
+ if (message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT') {
+ throw new Error('message channel is closed');
+ }
+ if (message.type === 'GET_STEP5_SUBMIT_STATE') {
+ throw new Error('content script still not ready');
+ }
+ throw new Error('unexpected message type: ' + message.type);
+}
+
+async function addLog(message, level, meta) { logs.push({ message, level, meta }); }
+async function waitForTabStableComplete() {}
+async function getTabId() { return 99; }
+function getErrorMessage(error) { return error?.message || String(error || ''); }
+
+${extractFunction('parseUrlSafely')}
+${extractFunction('isSignupEntryHost')}
+${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
+${extractFunction('isStep5CompletionChatgptUrl')}
+${extractFunction('advanceStep5PostSubmitPromptOnTab')}
+${extractFunction('getStep5SubmitStateFromContent')}
+${extractFunction('recoverStep5SubmitRetryPageOnTab')}
+${extractFunction('validateStep5PostCompletion')}
+
+return {
+ async run() {
+ return validateStep5PostCompletion(99, {
+ maxPostSubmitPromptActions: 4,
+ requireContentStateBeforeUrlSuccess: true,
+ });
+ },
+ snapshot() {
+ return { messages, logs, fallbackClicks };
+ },
+};
+`)();
+
+ const result = await api.run();
+ const snapshot = api.snapshot();
+
+ assert.equal(result.successState, 'logged_in_home');
+ assert.equal(result.postSubmitPromptActionsCompleted, true);
+ assert.equal(result.postSubmitPromptActionCount, 2);
+ assert.deepStrictEqual(snapshot.fallbackClicks, ['跳过', '继续']);
+ assert.equal(
+ snapshot.logs.some(({ message }) => /后台兜底已点击注册后弹窗按钮“跳过”/.test(message)),
+ true
+ );
+ assert.equal(
+ snapshot.logs.some(({ message }) => /后台兜底已点击注册后弹窗按钮“继续”/.test(message)),
+ true
+ );
+});
+
+test('step 5 post-completion validation ignores final prompt miss after two actions', async () => {
+ const api = new Function(`
+const messages = [];
+let promptAdvanceCount = 0;
+const chrome = {
+ tabs: {
+ async get() {
+ return { url: 'https://chatgpt.com/' };
+ },
+ },
+};
+
+async function sendToContentScriptResilient(source, message) {
+ messages.push({ source, type: message.type });
+ if (message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT') {
+ promptAdvanceCount += 1;
+ return promptAdvanceCount <= 2
+ ? { advanced: true, actionText: promptAdvanceCount === 1 ? '跳过' : '继续' }
+ : { advanced: false, reason: 'prompt_not_detected' };
+ }
+ if (message.type === 'GET_STEP5_SUBMIT_STATE') {
+ throw new Error('final prompt miss should complete before reading page state');
+ }
+ throw new Error('unexpected message type: ' + message.type);
+}
+
+async function addLog() {}
+function getErrorMessage(error) { return error?.message || String(error || ''); }
+async function waitForTabStableComplete() {}
+
+${extractFunction('parseUrlSafely')}
+${extractFunction('isSignupEntryHost')}
+${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
+${extractFunction('isStep5CompletionChatgptUrl')}
+${extractFunction('advanceStep5PostSubmitPromptOnTab')}
+${extractFunction('getStep5SubmitStateFromContent')}
+${extractFunction('recoverStep5SubmitRetryPageOnTab')}
+${extractFunction('validateStep5PostCompletion')}
+
+return {
+ async run() {
+ return validateStep5PostCompletion(99, {
+ maxPostSubmitPromptActions: 3,
+ });
+ },
+ snapshot() {
+ return { messages, promptAdvanceCount };
+ },
+};
+`)();
+
+ const result = await api.run();
+ const snapshot = api.snapshot();
+
+ assert.equal(result.successState, 'logged_in_home');
+ assert.equal(result.postSubmitPromptActionsCompleted, true);
+ assert.equal(result.postSubmitPromptActionCount, 2);
+ assert.equal(snapshot.promptAdvanceCount, 3);
+ assert.deepStrictEqual(
+ snapshot.messages.map(({ type }) => type),
+ [
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ 'ADVANCE_STEP5_POST_SUBMIT_PROMPT',
+ ]
+ );
+});
diff --git a/tests/background-step8-custom-mail.test.js b/tests/background-step8-custom-mail.test.js
new file mode 100644
index 00000000..59866ab6
--- /dev/null
+++ b/tests/background-step8-custom-mail.test.js
@@ -0,0 +1,60 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('flows/openai/background/steps/fetch-login-code.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundStep8;`)(globalScope);
+
+test('step 8 routes custom mail provider through resolver instead of manual confirmation', async () => {
+ let bypassCalls = 0;
+ let capturedMail = null;
+ let capturedState = null;
+ let capturedOptions = null;
+ const executor = api.createStep8Executor({
+ addLog: async () => {},
+ chrome: { tabs: { update: async () => {} } },
+ completeNodeFromBackground: async () => {},
+ confirmCustomVerificationStepBypass: async () => { bypassCalls += 1; },
+ ensureStep8VerificationPageReady: async () => ({
+ state: 'verification_page',
+ displayedEmail: 'target@example.com',
+ url: 'https://auth.openai.com/verify',
+ }),
+ getMailConfig: () => ({ provider: 'custom', label: '自定义邮箱' }),
+ getState: async () => ({ mailProvider: 'custom', email: 'target@example.com' }),
+ getTabId: async () => 1,
+ HOTMAIL_PROVIDER: 'hotmail-api',
+ isTabAlive: async () => false,
+ isVerificationMailPollingError: () => false,
+ LUCKMAIL_PROVIDER: 'luckmail-api',
+ CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+ CLOUD_MAIL_PROVIDER: 'cloudmail',
+ resolveVerificationStep: async (_step, state, mail, options) => {
+ capturedState = state;
+ capturedMail = mail;
+ capturedOptions = options;
+ },
+ rerunStep7ForStep8Recovery: async () => {},
+ resolveSignupEmailForFlow: async () => 'target@example.com',
+ reuseOrCreateTab: async () => {},
+ sendToContentScriptResilient: async () => ({}),
+ setState: async () => {},
+ shouldUseCustomRegistrationEmail: () => true,
+ sleepWithStop: async () => {},
+ STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
+ STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 1,
+ throwIfStopped: () => {},
+ });
+
+ await executor.executeStep8({
+ mailProvider: 'custom',
+ email: 'target@example.com',
+ oauthUrl: 'https://auth.openai.com/oauth',
+ });
+
+ assert.equal(bypassCalls, 0);
+ assert.equal(capturedMail.provider, 'custom');
+ assert.equal(capturedState.step8VerificationTargetEmail, 'target@example.com');
+ assert.equal(capturedOptions.targetEmail, 'target@example.com');
+});
\ No newline at end of file
diff --git a/tests/background-verification-flow-module.test.js b/tests/background-verification-flow-module.test.js
index aaecf886..8bdaf8d0 100644
--- a/tests/background-verification-flow-module.test.js
+++ b/tests/background-verification-flow-module.test.js
@@ -50,3 +50,38 @@ test('verification flow routes YYDS Mail provider to background poller', async (
assert.equal(pollCalls[0].step, 4);
assert.equal(pollCalls[0].payload.maxAttempts, 1);
});
+
+test('verification flow routes custom mail provider to local helper poller', async () => {
+ const source = fs.readFileSync('background/verification-flow.js', 'utf8');
+ const globalScope = {};
+ const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope);
+ const pollCalls = [];
+ const helpers = api.createVerificationFlowHelpers({
+ addLog: async () => {},
+ buildVerificationPollPayload: () => ({ maxAttempts: 1, intervalMs: 1, targetEmail: 'target@example.com' }),
+ CUSTOM_MAIL_PROVIDER: 'custom',
+ getState: async () => ({}),
+ getTabId: async () => 1,
+ isStopError: () => false,
+ pollCustomMailVerificationCode: async (step, state, payload) => {
+ pollCalls.push({ step, state, payload });
+ return { ok: true, code: '654321', emailTimestamp: 2, mailId: 'custom-msg-1' };
+ },
+ sendToContentScript: async () => ({}),
+ setState: async () => {},
+ sleepWithStop: async () => {},
+ throwIfStopped: () => {},
+ });
+
+ const result = await helpers.pollFreshVerificationCode(
+ 4,
+ { mailProvider: 'custom', email: 'target@example.com' },
+ { provider: 'custom', label: '自定义邮箱' },
+ { disableTimeBudgetCap: true }
+ );
+
+ assert.equal(result.code, '654321');
+ assert.equal(pollCalls.length, 1);
+ assert.equal(pollCalls[0].step, 4);
+ assert.equal(pollCalls[0].payload.targetEmail, 'target@example.com');
+});
diff --git a/tests/custom_mail_helper_test.py b/tests/custom_mail_helper_test.py
new file mode 100644
index 00000000..06c1dcc1
--- /dev/null
+++ b/tests/custom_mail_helper_test.py
@@ -0,0 +1,58 @@
+import importlib.util
+import os
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+
+
+MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "custom_mail_helper.py"
+spec = importlib.util.spec_from_file_location("custom_mail_helper", MODULE_PATH)
+custom_mail_helper = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(custom_mail_helper)
+
+
+class CustomMailHelperRandomEmailTest(unittest.TestCase):
+ def setUp(self):
+ self.temp_dir = tempfile.TemporaryDirectory()
+ self.db_path = os.path.join(self.temp_dir.name, "generated-emails.sqlite3")
+ custom_mail_helper.RANDOM_EMAIL_DB_PATH = self.db_path
+ custom_mail_helper.RANDOM_EMAIL_MAX_COUNT = 3
+
+ def tearDown(self):
+ self.temp_dir.cleanup()
+
+ def test_generates_one_unique_email_by_default_and_persists_it(self):
+ result = custom_mail_helper.generate_random_emails({"domain": "example.com"})
+
+ self.assertEqual(result["emails"], [result["email"]])
+ self.assertEqual(len(result["emails"]), 1)
+ self.assertRegex(result["email"], r"^[a-z]{8,12}@example\.com$")
+
+ with sqlite3.connect(self.db_path) as connection:
+ rows = connection.execute("SELECT email, domain FROM generated_emails").fetchall()
+ self.assertEqual(rows, [(result["email"], "example.com")])
+
+ def test_generates_requested_count_without_duplicates(self):
+ result = custom_mail_helper.generate_random_emails({"domain": "example.com", "n": 3})
+
+ self.assertEqual(len(result["emails"]), 3)
+ self.assertEqual(len(set(result["emails"])), 3)
+ for email in result["emails"]:
+ self.assertRegex(email, r"^[a-z]{8,12}@example\.com$")
+
+ with sqlite3.connect(self.db_path) as connection:
+ count = connection.execute("SELECT COUNT(*) FROM generated_emails").fetchone()[0]
+ self.assertEqual(count, 3)
+
+ def test_rejects_count_above_configured_maximum(self):
+ with self.assertRaisesRegex(RuntimeError, "must be <= 3"):
+ custom_mail_helper.generate_random_emails({"domain": "example.com", "n": 4})
+
+ def test_rejects_invalid_domain(self):
+ with self.assertRaisesRegex(RuntimeError, "Invalid domain"):
+ custom_mail_helper.generate_random_emails({"domain": "not a domain"})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/phone-verification-flow.test.js b/tests/phone-verification-flow.test.js
index 7663ba52..f395fec2 100644
--- a/tests/phone-verification-flow.test.js
+++ b/tests/phone-verification-flow.test.js
@@ -83,6 +83,74 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T
assert.equal(requests[1].searchParams.get('api_key'), 'demo-key');
});
+test('phone verification helper includes HeroSMS operator only when explicitly selected', async () => {
+ const requests = [];
+ const helpers = api.createPhoneVerificationHelpers({
+ addLog: async () => {},
+ ensureStep8SignupPageReady: async () => {},
+ fetchImpl: async (url) => {
+ const parsedUrl = new URL(url);
+ requests.push(parsedUrl);
+ const action = parsedUrl.searchParams.get('action');
+ if (action === 'getPrices') {
+ return {
+ ok: true,
+ text: async () => buildHeroSmsPricesPayload(),
+ };
+ }
+ return {
+ ok: true,
+ text: async () => 'ACCESS_NUMBER:123456:66959916439',
+ };
+ },
+ getState: async () => ({ heroSmsApiKey: 'demo-key' }),
+ sendToContentScriptResilient: async () => ({}),
+ setState: async () => {},
+ sleepWithStop: async () => {},
+ throwIfStopped: () => {},
+ });
+
+ await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsOperator: ' AIS ' });
+ await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsOperator: 'any' });
+
+ const numberRequests = requests.filter((request) => request.searchParams.get('action') === 'getNumber');
+ assert.equal(numberRequests[0].searchParams.get('operator'), 'ais');
+ assert.equal(numberRequests[1].searchParams.has('operator'), false);
+});
+
+test('phone verification helper uses persisted HeroSMS operator when acquisition state omits it', async () => {
+ const requests = [];
+ const helpers = api.createPhoneVerificationHelpers({
+ addLog: async () => {},
+ ensureStep8SignupPageReady: async () => {},
+ fetchImpl: async (url) => {
+ const parsedUrl = new URL(url);
+ requests.push(parsedUrl);
+ const action = parsedUrl.searchParams.get('action');
+ if (action === 'getPrices') {
+ return {
+ ok: true,
+ text: async () => buildHeroSmsPricesPayload(),
+ };
+ }
+ return {
+ ok: true,
+ text: async () => 'ACCESS_NUMBER:123456:66959916439',
+ };
+ },
+ getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsOperator: ' AIS ' }),
+ sendToContentScriptResilient: async () => ({}),
+ setState: async () => {},
+ sleepWithStop: async () => {},
+ throwIfStopped: () => {},
+ });
+
+ await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
+
+ const numberRequest = requests.find((request) => request.searchParams.get('action') === 'getNumber');
+ assert.equal(numberRequest.searchParams.get('operator'), 'ais');
+});
+
test('signup phone helper persists signup runtime state without touching add-phone activation', async () => {
const setStateCalls = [];
let currentState = {
@@ -2383,26 +2451,26 @@ test('phone verification helper treats fiveSimReuseEnabled as legacy-only when p
test('phone verification helper rejects 5sim maxPrice with custom operator before buying', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
- addLog: async () => {},
- ensureStep8SignupPageReady: async () => {},
- fetchImpl: async (url) => {
- requests.push(url);
- throw new Error(`Unexpected 5sim request: ${url}`);
- },
- getState: async () => ({
- phoneSmsProvider: '5sim',
- fiveSimApiKey: 'five-token',
- fiveSimCountryOrder: ['vietnam'],
- fiveSimOperator: 'virtual21',
- fiveSimMaxPrice: '0.1',
- heroSmsActivationRetryRounds: 1,
- }),
- sendToContentScriptResilient: async () => ({}),
- setState: async () => {},
- sleepWithStop: async () => {},
- throwIfStopped: () => {},
- });
-
+ addLog: async () => {},
+ ensureStep8SignupPageReady: async () => {},
+ fetchImpl: async (url) => {
+ requests.push(url);
+ throw new Error(`Unexpected 5sim request: ${url}`);
+ },
+ getState: async () => ({
+ phoneSmsProvider: '5sim',
+ fiveSimApiKey: 'five-token',
+ fiveSimCountryOrder: ['vietnam'],
+ fiveSimOperator: 'virtual21',
+ fiveSimMaxPrice: '0.1',
+ heroSmsActivationRetryRounds: 1,
+ }),
+ sendToContentScriptResilient: async () => ({}),
+ setState: async () => {},
+ sleepWithStop: async () => {},
+ throwIfStopped: () => {},
+ });
+
await assert.rejects(
() => helpers.requestPhoneActivation({
phoneSmsProvider: '5sim',
diff --git a/tests/sidepanel-custom-email-pool.test.js b/tests/sidepanel-custom-email-pool.test.js
index 47a9e49c..3368d5c0 100644
--- a/tests/sidepanel-custom-email-pool.test.js
+++ b/tests/sidepanel-custom-email-pool.test.js
@@ -187,6 +187,20 @@ test('sidepanel queues custom email pool refresh when the pool row is visible',
);
});
+test('sidepanel syncs custom mail provider pool from background data updates', () => {
+ assert.match(
+ source,
+ /message\.payload\.customMailProviderPool !== undefined[\s\S]*inputCustomMailProviderPool\.value = normalizeCustomEmailPoolEntries\(message\.payload\.customMailProviderPool\)\.join\('\\n'\)/
+ );
+});
+
+test('sidepanel syncs current registration email from background data updates', () => {
+ assert.match(
+ source,
+ /message\.payload\.email !== undefined && inputEmail[\s\S]*inputEmail\.value = message\.payload\.email \|\| ''/
+ );
+});
+
test('sidepanel custom verification dialog exposes add-phone action for step 8', async () => {
const bundle = [
extractFunction('getCustomVerificationPromptCopy'),
diff --git a/tests/sidepanel-flow-source-registry.test.js b/tests/sidepanel-flow-source-registry.test.js
index 67cf0e63..4df0239d 100644
--- a/tests/sidepanel-flow-source-registry.test.js
+++ b/tests/sidepanel-flow-source-registry.test.js
@@ -177,6 +177,201 @@ return {
assert.equal(api.getCalls()[0].targetId, 'kiro-rs');
});
+test('syncLatestState rebuilds workflow nodes before filtering incoming node statuses', () => {
+ const bundle = [
+ extractFunction(sidepanelSource, 'syncLatestState'),
+ ].join('\n');
+
+ const api = new Function(`
+let latestState = {
+ activeFlowId: 'openai',
+ flowId: 'openai',
+ nodeStatuses: { 'open-chatgpt': 'completed' },
+};
+const DEFAULT_ACTIVE_FLOW_ID = 'openai';
+let NODE_IDS = ['open-chatgpt'];
+let NODE_DEFAULT_STATUSES = { 'open-chatgpt': 'pending' };
+const calls = [];
+function normalizeFlowId(value = '', fallback = DEFAULT_ACTIVE_FLOW_ID) {
+ return String(value || fallback || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase() || DEFAULT_ACTIVE_FLOW_ID;
+}
+function syncStepDefinitionsFromUiState(stateOverrides = {}) {
+ calls.push({ type: 'sync-definitions', activeFlowId: stateOverrides.activeFlowId || stateOverrides.flowId });
+ if ((stateOverrides.activeFlowId || stateOverrides.flowId) === 'kiro') {
+ NODE_IDS = ['kiro-open-register-page', 'kiro-submit-email'];
+ NODE_DEFAULT_STATUSES = {
+ 'kiro-open-register-page': 'pending',
+ 'kiro-submit-email': 'pending',
+ };
+ }
+}
+function getStoredNodeStatuses(state = {}) {
+ const source = { ...NODE_DEFAULT_STATUSES, ...(state?.nodeStatuses || {}) };
+ return Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, source[nodeId] || 'pending']));
+}
+function renderAccountRecords(state) {
+ calls.push({ type: 'render', state: { ...state } });
+}
+${bundle}
+return {
+ syncLatestState,
+ getLatestState() {
+ return latestState;
+ },
+ getCalls() {
+ return calls;
+ },
+};
+`)();
+
+ api.syncLatestState({
+ activeFlowId: 'kiro',
+ nodeStatuses: {
+ 'open-chatgpt': 'completed',
+ 'kiro-open-register-page': 'running',
+ },
+ });
+
+ assert.deepStrictEqual(api.getLatestState().nodeStatuses, {
+ 'kiro-open-register-page': 'running',
+ 'kiro-submit-email': 'pending',
+ });
+ assert.equal(Object.prototype.hasOwnProperty.call(api.getLatestState().nodeStatuses, 'open-chatgpt'), false);
+ assert.deepStrictEqual(api.getCalls()[0], { type: 'sync-definitions', activeFlowId: 'kiro' });
+});
+
+test('syncLatestState rebuilds workflow nodes when target changes', () => {
+ const bundle = [
+ extractFunction(sidepanelSource, 'syncLatestState'),
+ ].join('\n');
+
+ const api = new Function(`
+let latestState = {
+ activeFlowId: 'openai',
+ flowId: 'openai',
+ targetId: 'sub2api',
+ nodeStatuses: { 'sub2api-session-import': 'completed' },
+};
+const DEFAULT_ACTIVE_FLOW_ID = 'openai';
+let NODE_IDS = ['sub2api-session-import'];
+let NODE_DEFAULT_STATUSES = { 'sub2api-session-import': 'pending' };
+const calls = [];
+function normalizeFlowId(value = '', fallback = DEFAULT_ACTIVE_FLOW_ID) {
+ return String(value || fallback || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase() || DEFAULT_ACTIVE_FLOW_ID;
+}
+function syncStepDefinitionsFromUiState(stateOverrides = {}) {
+ calls.push({ type: 'sync-definitions', targetId: stateOverrides.targetId });
+ if (stateOverrides.targetId === 'cpa') {
+ NODE_IDS = ['cpa-session-import'];
+ NODE_DEFAULT_STATUSES = { 'cpa-session-import': 'pending' };
+ }
+}
+function getStoredNodeStatuses(state = {}) {
+ const source = { ...NODE_DEFAULT_STATUSES, ...(state?.nodeStatuses || {}) };
+ return Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, source[nodeId] || 'pending']));
+}
+function renderAccountRecords(state) {
+ calls.push({ type: 'render', state: { ...state } });
+}
+${bundle}
+return {
+ syncLatestState,
+ getLatestState() {
+ return latestState;
+ },
+ getCalls() {
+ return calls;
+ },
+};
+`)();
+
+ api.syncLatestState({
+ targetId: 'cpa',
+ nodeStatuses: {
+ 'sub2api-session-import': 'completed',
+ 'cpa-session-import': 'running',
+ },
+ });
+
+ assert.deepStrictEqual(api.getLatestState().nodeStatuses, {
+ 'cpa-session-import': 'running',
+ });
+ assert.equal(Object.prototype.hasOwnProperty.call(api.getLatestState().nodeStatuses, 'sub2api-session-import'), false);
+ assert.deepStrictEqual(api.getCalls()[0], { type: 'sync-definitions', targetId: 'cpa' });
+});
+
+test('applySettingsState passes capability resolved Plus account access strategy into step definitions', () => {
+ const body = extractFunction(sidepanelSource, 'applySettingsState');
+ assert.match(body, /resolveStepDefinitionCapabilityState\(state/);
+ assert.match(body, /plusAccountAccessStrategy:\s*stepDefinitionState\.plusAccountAccessStrategy/);
+});
+
+test('AUTO_RUN_RESET refreshes sidepanel from background state after local reset', () => {
+ const resetCaseIndex = sidepanelSource.indexOf("case 'AUTO_RUN_RESET'");
+ assert.notEqual(resetCaseIndex, -1, 'AUTO_RUN_RESET handler should exist');
+ const dataUpdatedCaseIndex = sidepanelSource.indexOf("case 'DATA_UPDATED'", resetCaseIndex);
+ assert.notEqual(dataUpdatedCaseIndex, -1, 'DATA_UPDATED handler should follow AUTO_RUN_RESET');
+ const resetCaseBody = sidepanelSource.slice(resetCaseIndex, dataUpdatedCaseIndex);
+
+ assert.match(resetCaseBody, /chrome\.runtime\.sendMessage\(\{\s*type:\s*'GET_STATE'\s*\}\)/);
+ assert.match(resetCaseBody, /syncLatestState\(state\)/);
+ assert.match(resetCaseBody, /applySettingsState\(state\)/);
+});
+
+test('updateNodeUI ignores stale nodes outside the current workflow', () => {
+ const bundle = [
+ extractFunction(sidepanelSource, 'updateNodeUI'),
+ ].join('\n');
+
+ const api = new Function(`
+let latestState = {
+ nodeStatuses: {
+ 'kiro-open-register-page': 'pending',
+ 'kiro-submit-email': 'pending',
+ },
+};
+const NODE_IDS = ['kiro-open-register-page', 'kiro-submit-email'];
+const calls = [];
+function getStoredNodeStatuses() {
+ return { ...latestState.nodeStatuses };
+}
+function syncLatestState(nextState = {}) {
+ calls.push({ type: 'sync', nextState });
+ latestState = { ...latestState, ...nextState };
+}
+function renderSingleNodeStatus(nodeId, status) {
+ calls.push({ type: 'render-single', nodeId, status });
+}
+function updateButtonStates() {
+ calls.push({ type: 'buttons' });
+}
+function updateProgressCounter() {
+ calls.push({ type: 'progress' });
+}
+function updateConfigMenuControls() {
+ calls.push({ type: 'config' });
+}
+${bundle}
+return {
+ updateNodeUI,
+ getLatestState() {
+ return latestState;
+ },
+ getCalls() {
+ return calls;
+ },
+};
+`)();
+
+ api.updateNodeUI('open-chatgpt', 'completed');
+
+ assert.deepStrictEqual(api.getLatestState().nodeStatuses, {
+ 'kiro-open-register-page': 'pending',
+ 'kiro-submit-email': 'pending',
+ });
+ assert.deepStrictEqual(api.getCalls(), []);
+});
+
test('updatePanelModeUI reapplies dynamic Plus and phone visibility after flow group visibility', () => {
const bundle = [
extractFunction(sidepanelSource, 'updatePanelModeUI'),
diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js
index 144dbdbd..8757ba1a 100644
--- a/tests/sidepanel-phone-verification-settings.test.js
+++ b/tests/sidepanel-phone-verification-settings.test.js
@@ -85,6 +85,8 @@ test('sidepanel html exposes phone verification toggle and multi-provider SMS ro
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-operator"/);
+ assert.match(html, /id="select-hero-sms-operator"/);
assert.match(html, /id="select-phone-sms-provider"/);
assert.match(html, /\.\.\/phone-sms\/providers\/hero-sms\.js/);
assert.match(html, /\.\.\/phone-sms\/providers\/five-sim\.js/);
@@ -161,6 +163,9 @@ test('sidepanel loads live SMS country lists silently during startup', () => {
assert.match(sidepanelSource, /loadHeroSmsCountries\(\{ silent: true \}\)/);
assert.match(sidepanelSource, /loadFiveSimCountries\(\{ silent: true \}\)/);
assert.match(sidepanelSource, /await loadHeroSmsCountries\(\{ silent: true \}\);/);
+ assert.doesNotMatch(heroLoader, /await loadHeroSmsOperators/);
+ assert.doesNotMatch(heroLoader, /refreshHeroSmsOperatorOptions\(/);
+ assert.doesNotMatch(heroLoader, /renderHeroSmsOperatorOptions\(/);
assert.doesNotMatch(sidepanelSource, /loadHeroSmsCountries\(\{ silent: true, preferFallbackOnly: true \}\)/);
assert.doesNotMatch(sidepanelSource, /loadFiveSimCountries\(\{ silent: true, preferFallbackOnly: true \}\)/);
assert.doesNotMatch(sidepanelSource, /console\.error\('加载 (?:HeroSMS|5sim|NexSMS) 国家列表失败:'/);
@@ -195,6 +200,258 @@ return { parseHeroSmsCountryPayload };
);
});
+test('HeroSMS operator parser accepts countryOperators payload from the live API', () => {
+ const api = new Function(`
+const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
+${extractFunction('normalizeHeroSmsCountryId')}
+${extractFunction('normalizeHeroSmsOperatorValue')}
+${extractFunction('parseHeroSmsOperatorsPayload')}
+return { parseHeroSmsOperatorsPayload };
+`)();
+
+ const parsed = api.parseHeroSmsOperatorsPayload({
+ status: 'success',
+ countryOperators: {
+ 52: ['AIS', 'dtac', 'ais', 'true-move'],
+ 153: ['ogero', 'alfa', 'touch'],
+ bad: ['ignored'],
+ },
+ });
+
+ assert.deepStrictEqual(parsed.get('52'), ['ais', 'dtac', 'true-move']);
+ assert.deepStrictEqual(parsed.get('153'), ['ogero', 'alfa', 'touch']);
+ assert.equal(parsed.has('0'), false);
+});
+
+test('HeroSMS operator rendering does not re-enter country synchronization', () => {
+ const api = new Function(`
+const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
+let latestState = { heroSmsCountryId: 52, heroSmsOperator: 'ais' };
+let heroSmsCountrySelectionOrder = [52];
+let isRenderingHeroSmsOperatorOptions = false;
+const selectHeroSmsCountry = null;
+const selectHeroSmsCountryFallback = null;
+const heroSmsOperatorsByCountryId = new Map([['52', ['ais', 'dtac']]]);
+const selectHeroSmsOperator = {
+ value: 'ais',
+ innerHTML: '',
+ options: [],
+ appendChild(option) { this.options.push(option); },
+ disabled: false,
+};
+const document = {
+ createElement() { return { value: '', textContent: '' }; },
+};
+function getSelectedHeroSmsCountryOption() {
+ throw new Error('renderHeroSmsOperatorOptions must not sync country selection');
+}
+${extractFunction('normalizeHeroSmsCountryId')}
+${extractFunction('normalizeHeroSmsOperatorValue')}
+${extractFunction('getHeroSmsOperatorCountryId')}
+${extractFunction('renderHeroSmsOperatorOptions')}
+return { renderHeroSmsOperatorOptions, selectHeroSmsOperator };
+`)();
+
+ api.renderHeroSmsOperatorOptions('dtac');
+
+ assert.equal(api.selectHeroSmsOperator.value, 'dtac');
+ assert.deepStrictEqual(
+ api.selectHeroSmsOperator.options.map((option) => option.value),
+ ['any', 'ais', 'dtac']
+ );
+});
+
+test('HeroSMS operator helpers tolerate panels without the new operator select', async () => {
+ const api = new Function(`
+const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
+let latestState = { heroSmsCountryId: 52, heroSmsOperator: 'ais' };
+let heroSmsCountrySelectionOrder = [52];
+let isRenderingHeroSmsOperatorOptions = false;
+const selectHeroSmsCountry = null;
+const selectHeroSmsCountryFallback = null;
+const selectHeroSmsOperator = null;
+let heroSmsOperatorsByCountryId = new Map([['52', ['ais']]]);
+let heroSmsOperatorsLoadedAt = Date.now();
+${extractFunction('normalizeHeroSmsCountryId')}
+${extractFunction('normalizeHeroSmsOperatorValue')}
+${extractFunction('getHeroSmsOperatorCountryId')}
+${extractFunction('loadHeroSmsOperators')}
+${extractFunction('refreshHeroSmsOperatorOptions')}
+${extractFunction('renderHeroSmsOperatorOptions')}
+return { refreshHeroSmsOperatorOptions, renderHeroSmsOperatorOptions };
+`)();
+
+ assert.doesNotThrow(() => api.renderHeroSmsOperatorOptions('ais'));
+ await assert.doesNotReject(() => api.refreshHeroSmsOperatorOptions({ silent: true }));
+});
+
+test('HeroSMS operator refresh preserves the latest user selection', async () => {
+ const api = new Function(`
+const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
+let latestState = { heroSmsCountryId: 52, heroSmsOperator: 'any' };
+let heroSmsCountrySelectionOrder = [52];
+let isRenderingHeroSmsOperatorOptions = false;
+let heroSmsOperatorsByCountryId = new Map([['52', ['ais', 'dtac']]]);
+let heroSmsOperatorsLoadedAt = Date.now();
+const selectHeroSmsCountry = null;
+const selectHeroSmsCountryFallback = null;
+const selectHeroSmsOperator = {
+ value: 'any',
+ innerHTML: '',
+ options: [],
+ appendChild(option) { this.options.push(option); },
+ disabled: false,
+};
+const document = {
+ createElement() { return { value: '', textContent: '' }; },
+};
+${extractFunction('normalizeHeroSmsCountryId')}
+${extractFunction('normalizeHeroSmsOperatorValue')}
+${extractFunction('getHeroSmsOperatorCountryId')}
+${extractFunction('loadHeroSmsOperators')}
+${extractFunction('refreshHeroSmsOperatorOptions')}
+${extractFunction('renderHeroSmsOperatorOptions')}
+return { refreshHeroSmsOperatorOptions, selectHeroSmsOperator };
+`)();
+
+ const pendingRefresh = api.refreshHeroSmsOperatorOptions({ silent: true });
+ api.selectHeroSmsOperator.value = 'ais';
+ await pendingRefresh;
+
+ assert.equal(api.selectHeroSmsOperator.value, 'ais');
+});
+
+test('HeroSMS operator value setter keeps selected operator even before options load', () => {
+ const api = new Function(`
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
+let latestState = { heroSmsOperator: 'ais' };
+const selectHeroSmsOperator = {
+ value: 'any',
+ options: [{ value: 'any', textContent: '不限(any)' }],
+ appendChild(option) { this.options.push(option); },
+};
+const document = {
+ createElement() { return { value: '', textContent: '' }; },
+};
+${extractFunction('normalizeHeroSmsOperatorValue')}
+${extractFunction('setHeroSmsOperatorSelectValue')}
+return { setHeroSmsOperatorSelectValue, selectHeroSmsOperator };
+`)();
+
+ api.setHeroSmsOperatorSelectValue('ais');
+
+ assert.equal(api.selectHeroSmsOperator.value, 'ais');
+ assert.deepStrictEqual(
+ api.selectHeroSmsOperator.options.map((option) => option.value),
+ ['any', 'ais']
+ );
+});
+
+test('HeroSMS operator rendering keeps current operator when API list misses it', () => {
+ const api = new Function(`
+const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
+let latestState = { heroSmsCountryId: 52, heroSmsOperator: 'ais' };
+let heroSmsCountrySelectionOrder = [52];
+let isRenderingHeroSmsOperatorOptions = false;
+const selectHeroSmsCountry = null;
+const selectHeroSmsCountryFallback = null;
+const heroSmsOperatorsByCountryId = new Map([['52', ['dtac']]]);
+const selectHeroSmsOperator = {
+ value: 'ais',
+ innerHTML: '',
+ options: [],
+ appendChild(option) { this.options.push(option); },
+ disabled: false,
+};
+const document = {
+ createElement() { return { value: '', textContent: '' }; },
+};
+${extractFunction('normalizeHeroSmsCountryId')}
+${extractFunction('normalizeHeroSmsOperatorValue')}
+${extractFunction('getHeroSmsOperatorCountryId')}
+${extractFunction('renderHeroSmsOperatorOptions')}
+return { renderHeroSmsOperatorOptions, selectHeroSmsOperator };
+`)();
+
+ api.renderHeroSmsOperatorOptions();
+
+ assert.equal(api.selectHeroSmsOperator.value, 'ais');
+ assert.deepStrictEqual(
+ api.selectHeroSmsOperator.options.map((option) => option.value),
+ ['any', 'dtac', 'ais']
+ );
+});
+
+test('HeroSMS country sync does not render operator options recursively', () => {
+ const syncSource = extractFunction('syncHeroSmsFallbackSelectionOrderFromSelect');
+
+ assert.doesNotMatch(syncSource, /renderHeroSmsOperatorOptions\(/);
+});
+
+test('HeroSMS platform display does not synchronize country selection', () => {
+ const api = new Function(`
+const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
+const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
+const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
+const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
+const DEFAULT_FIVE_SIM_COUNTRY_ID = 'thailand';
+const DEFAULT_FIVE_SIM_COUNTRY_LABEL = 'Thailand';
+const DEFAULT_NEX_SMS_COUNTRY_ORDER = [1];
+let latestState = { phoneSmsProvider: 'hero-sms', heroSmsCountryId: 52, heroSmsCountryLabel: 'Thailand' };
+let heroSmsCountrySelectionOrder = [52];
+const selectHeroSmsCountry = null;
+const selectHeroSmsCountryFallback = null;
+const displayHeroSmsPlatform = { textContent: '' };
+const inputHeroSmsApiKey = { placeholder: '' };
+function getSelectedPhoneSmsProvider() { return 'hero-sms'; }
+function getPhoneSmsProviderLabel() { return 'HeroSMS'; }
+function getSelectedFiveSimCountries() { return []; }
+function getSelectedNexSmsCountries() { return []; }
+function getHeroSmsCountryLabelById(id) { return id === '52' ? 'Thailand' : ''; }
+function syncHeroSmsFallbackSelectionOrderFromSelect() {
+ throw new Error('updateHeroSmsPlatformDisplay must not sync country selection');
+}
+${extractFunction('normalizeHeroSmsCountryId')}
+${extractFunction('normalizeHeroSmsCountryLabel')}
+${extractFunction('peekSelectedHeroSmsCountryOption')}
+${extractFunction('updateHeroSmsPlatformDisplay')}
+return { displayHeroSmsPlatform, updateHeroSmsPlatformDisplay };
+`)();
+
+ assert.doesNotThrow(() => api.updateHeroSmsPlatformDisplay());
+ assert.equal(api.displayHeroSmsPlatform.textContent, 'HeroSMS / OpenAI / Thailand');
+});
+
+test('HeroSMS operator startup paths only set value without loading options', () => {
+ assert.match(sidepanelSource, /function setHeroSmsOperatorSelectValue\(/);
+ assert.doesNotMatch(
+ sidepanelSource,
+ /renderHeroSmsOperatorOptions\(state\?\.heroSmsOperator\)/
+ );
+ assert.doesNotMatch(
+ sidepanelSource,
+ /renderHeroSmsOperatorOptions\(latestState\?\.heroSmsOperator\)/
+ );
+ assert.doesNotMatch(
+ sidepanelSource,
+ /renderHeroSmsOperatorOptions\(message\.payload\.heroSmsOperator\)/
+ );
+ assert.match(sidepanelSource, /selectHeroSmsOperator\?\.addEventListener\('focus',/);
+ assert.match(sidepanelSource, /selectHeroSmsOperator\?\.addEventListener\('pointerdown',/);
+});
+
+test('HeroSMS operator change syncs latest state before autosave', () => {
+ assert.match(
+ sidepanelSource,
+ /selectHeroSmsOperator\?\.addEventListener\('change',[\s\S]*?syncLatestState\(\{ heroSmsOperator: nextOperator \}\);[\s\S]*?saveSettings\(\{ silent: true \}\)/
+ );
+});
+
test('sidepanel source wires free reusable phone save and clear actions to runtime messages', () => {
assert.match(sidepanelSource, /const inputFreePhoneReuseEnabled = document\.getElementById\('input-free-phone-reuse-enabled'\);/);
assert.match(sidepanelSource, /const inputFreePhoneReuseAutoEnabled = document\.getElementById\('input-free-phone-reuse-auto-enabled'\);/);
@@ -660,6 +917,7 @@ const rowHeroSmsPlatform = { style: { display: 'none' } };
const rowHeroSmsCountry = { style: { display: 'none' } };
const rowHeroSmsCountryFallback = { style: { display: 'none' } };
const rowHeroSmsAcquirePriority = { style: { display: 'none' } };
+const rowHeroSmsOperator = { style: { display: 'none' } };
const rowHeroSmsApiKey = { style: { display: 'none' } };
const rowHeroSmsMaxPrice = { style: { display: 'none' } };
const rowFiveSimApiKey = { style: { display: 'none' } };
@@ -742,6 +1000,7 @@ return {
rowHeroSmsCountry,
rowHeroSmsCountryFallback,
rowHeroSmsAcquirePriority,
+ rowHeroSmsOperator,
rowHeroSmsApiKey,
rowHeroSmsMaxPrice,
rowFiveSimApiKey,
@@ -990,6 +1249,7 @@ const inputVerificationResendCount = { value: '4' };
const inputHeroSmsApiKey = { value: 'demo-key' };
const inputFiveSimApiKey = { value: 'five-sim-key' };
const inputFiveSimOperator = { value: 'any' };
+const selectHeroSmsOperator = { value: 'ais' };
const inputFiveSimProduct = { value: 'openai' };
const inputNexSmsApiKey = { value: 'nex-key' };
const inputNexSmsServiceCode = { value: 'ot' };
@@ -1037,6 +1297,7 @@ const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
@@ -1104,6 +1365,7 @@ ${extractFunction('normalizeHeroSmsReuseEnabledValue')}
${extractFunction('normalizeHeroSmsAcquirePriority')}
${extractFunction('normalizeHeroSmsCountryId')}
${extractFunction('normalizeHeroSmsCountryLabel')}
+${extractFunction('normalizeHeroSmsOperatorValue')}
${extractFunction('getSelectedHeroSmsCountryOption')}
${extractFunction('normalizeSignupMethod')}
${extractFunction('isPhoneSignupReuseLocked')}
@@ -1150,6 +1412,7 @@ return { collectSettingsPayload };
assert.equal(payload.heroSmsMinPrice, '0.03');
assert.equal(payload.heroSmsMaxPrice, '0.12');
assert.equal(payload.heroSmsPreferredPrice, '0.0512');
+ assert.equal(payload.heroSmsOperator, 'ais');
assert.deepStrictEqual(payload.phonePreferredActivation, {
provider: 'hero-sms',
activationId: 'stored-activation',
@@ -1197,6 +1460,7 @@ const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
+const DEFAULT_HERO_SMS_OPERATOR = 'any';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const selectPhoneSmsProvider = { value: 'hero-sms', dataset: { activeProvider: 'hero-sms' } };
@@ -1204,6 +1468,7 @@ const inputHeroSmsApiKey = { value: 'hero-live' };
const inputHeroSmsMinPrice = { value: '0.03' };
const inputHeroSmsMaxPrice = { value: '0.22' };
const inputFiveSimOperator = { value: 'any' };
+const selectHeroSmsOperator = { value: 'ais', innerHTML: '', options: [], appendChild(option) { this.options.push(option); }, disabled: false };
const displayHeroSmsPriceTiers = { textContent: '' };
const displayPhoneSmsBalance = { textContent: '' };
const rowHeroSmsPriceTiers = { style: { display: '' } };
@@ -1223,6 +1488,7 @@ ${extractFunction('normalizePhoneSmsMinPriceValue')}
${extractFunction('normalizePhoneSmsMaxPriceValue')}
${extractFunction('normalizeHeroSmsCountryId')}
${extractFunction('normalizeHeroSmsCountryLabel')}
+${extractFunction('normalizeHeroSmsOperatorValue')}
${extractFunction('normalizeHeroSmsCountryFallbackList')}
${extractFunction('normalizeFiveSimCountryFallbackList')}
function getSelectedHeroSmsCountryOption() {
@@ -1238,6 +1504,8 @@ function syncHeroSmsFallbackSelectionOrderFromSelect() {
function syncLatestState(patch) { latestState = { ...latestState, ...patch }; }
function loadHeroSmsCountries() { return Promise.resolve(); }
function applyHeroSmsFallbackSelection() {}
+function renderHeroSmsOperatorOptions(value) { selectHeroSmsOperator.value = normalizeHeroSmsOperatorValue(value || selectHeroSmsOperator.value); }
+${extractFunction('setHeroSmsOperatorSelectValue')}
function updatePhoneVerificationSettingsUI() {}
function markSettingsDirty() {}
function saveSettings() { savedPayload = { ...latestState }; return Promise.resolve(); }
diff --git a/tests/step4-submit-retry-recovery.test.js b/tests/step4-submit-retry-recovery.test.js
index 0035e176..768933e8 100644
--- a/tests/step4-submit-retry-recovery.test.js
+++ b/tests/step4-submit-retry-recovery.test.js
@@ -239,3 +239,89 @@ return {
success: true,
});
});
+
+test('waitForVerificationSubmitOutcome advances post-verification interstitials until step 5', async () => {
+ const api = new Function(`
+const clicks = [];
+const skipButton = {
+ textContent: 'Skip for now',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const location = { href: 'https://chatgpt.com/onboarding' };
+const document = {
+ body: {
+ innerText: 'What brings you to ChatGPT? Tell us about yourself. Skip for now',
+ textContent: 'What brings you to ChatGPT? Tell us about yourself. Skip for now',
+ },
+ querySelector() { return null; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return [skipButton];
+ }
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log() {}
+function getVerificationErrorText() { return ''; }
+function isStep5Ready() { return location.href === 'https://auth.openai.com/create-account/profile'; }
+function isStep5ProfileStillVisible() { return isStep5Ready(); }
+function isStep8Ready() { return false; }
+function isAddPhonePageReady() { return false; }
+function isVerificationPageStillVisible() { return false; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getActionText(el) { return el?.textContent || ''; }
+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 humanPause() {}
+function simulateClick(el) {
+ clicks.push(el?.textContent || 'clicked');
+ location.href = 'https://auth.openai.com/create-account/profile';
+ document.body.innerText = 'Tell us about you Birthday';
+ document.body.textContent = document.body.innerText;
+}
+async function sleep() {}
+
+${extractFunction('isSignupProfilePageUrl')}
+${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
+${extractFunction('isStep5CompletionChatgptUrl')}
+${extractFunction('getStep5PostSubmitSuccessState')}
+${extractFunction('findStep5PostSubmitOnboardingAction')}
+${extractFunction('isStep5PostSubmitOnboardingPage')}
+${extractFunction('getStep4PostVerificationState')}
+${extractFunction('advanceStep4PostVerificationInterstitialPage')}
+${extractFunction('waitForVerificationSubmitOutcome')}
+
+return {
+ run() {
+ return waitForVerificationSubmitOutcome(4, 1000);
+ },
+ snapshot() {
+ return { clicks, href: location.href };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, { success: true });
+ assert.deepStrictEqual(api.snapshot(), {
+ clicks: ['Skip for now'],
+ href: 'https://auth.openai.com/create-account/profile',
+ });
+});
diff --git a/tests/step5-age-consent.test.js b/tests/step5-age-consent.test.js
index 0df43671..ae74fac2 100644
--- a/tests/step5-age-consent.test.js
+++ b/tests/step5-age-consent.test.js
@@ -68,6 +68,9 @@ function getStep5Bundle() {
extractFunction('isStep5ProfileStillVisible'),
extractFunction('isStep5CompletionChatgptUrl'),
extractFunction('getStep5PostSubmitSuccessState'),
+ extractFunction('findStep5PostSubmitOnboardingAction'),
+ extractFunction('isStep5PostSubmitOnboardingPage'),
+ extractFunction('advanceStep5PostSubmitOnboardingPage'),
extractFunction('installStep5NavigationCompletionReporter'),
extractFunction('waitForStep5SubmitOutcome'),
extractFunction('step5_fillNameBirthday'),
diff --git a/tests/step5-direct-complete.test.js b/tests/step5-direct-complete.test.js
index db01ebb7..cf4b9130 100644
--- a/tests/step5-direct-complete.test.js
+++ b/tests/step5-direct-complete.test.js
@@ -63,6 +63,9 @@ function getStep5OutcomeBundle() {
extractFunction('isStep5ProfileStillVisible'),
extractFunction('isStep5CompletionChatgptUrl'),
extractFunction('getStep5PostSubmitSuccessState'),
+ extractFunction('findStep5PostSubmitOnboardingAction'),
+ extractFunction('isStep5PostSubmitOnboardingPage'),
+ extractFunction('advanceStep5PostSubmitOnboardingPage'),
extractFunction('installStep5NavigationCompletionReporter'),
extractFunction('waitForStep5SubmitOutcome'),
].join('\n');
@@ -1046,6 +1049,755 @@ return {
assert.equal(api.snapshot().recoverCalls, 1);
});
+test('step 5 advances post-submit onboarding survey page before completing', async () => {
+ const api = new Function(`
+let now = 0;
+const clicks = [];
+let promptIndex = 0;
+const skipButton = {
+ textContent: 'Skip for now',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const continueButton = {
+ textContent: 'Continue',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const location = {
+ href: 'https://chatgpt.com/onboarding',
+};
+const document = {
+ body: {
+ innerText: 'What brings you to ChatGPT? Tell us about yourself. Skip for now',
+ },
+ querySelector() { return null; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return promptIndex === 0 ? [skipButton] : promptIndex === 1 ? [continueButton] : [];
+ }
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log() {}
+async function sleep(ms = 0) { now += ms || 250; }
+async function humanPause() {}
+function simulateClick(el) {
+ clicks.push(el?.textContent || 'clicked');
+ promptIndex += 1;
+ location.href = 'https://chatgpt.com/';
+ document.body.innerText = promptIndex === 1
+ ? 'You are ready to go Continue'
+ : 'ChatGPT';
+}
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getActionText(el) { return el?.textContent || ''; }
+function getSignupAuthRetryPathPatterns() { return []; }
+function getAuthTimeoutErrorPageState() { return null; }
+async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); }
+function createSignupUserAlreadyExistsError() { return new Error('user already exists'); }
+function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); }
+function getStep5ErrorText() { return ''; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isStep5Ready() { return false; }
+function isLikelyLoggedInChatgptHomeUrl() { return location.href === 'https://chatgpt.com/'; }
+function isOAuthConsentPage() { return false; }
+function isAddPhonePageReady() { return false; }
+
+${extractFunction('isSignupProfilePageUrl')}
+${getStep5OutcomeBundle()}
+
+return {
+ run() {
+ return waitForStep5SubmitOutcome({ timeoutMs: 3000 });
+ },
+ snapshot() {
+ return { clicks, now };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, {
+ state: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount: 2,
+ });
+ assert.deepStrictEqual(api.snapshot().clicks, ['Skip for now', 'Continue']);
+});
+
+test('step 5 skips post-submit prompt when about-you url remains visible', async () => {
+ const api = new Function(`
+let now = 0;
+const clicks = [];
+let promptIndex = 0;
+const submitButton = {
+ textContent: '完成帐户创建',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const skipButton = {
+ textContent: '跳过',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const continueButton = {
+ textContent: '继续',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const location = {
+ href: 'https://auth.openai.com/about-you',
+};
+const document = {
+ body: {
+ innerText: '欢迎使用 ChatGPT 你可以稍后完成这些设置 跳过',
+ },
+ querySelector(selector) {
+ if (selector === 'button[type="submit"]') return submitButton;
+ return null;
+ },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return promptIndex === 0 ? [submitButton, skipButton] : promptIndex === 1 ? [continueButton] : [];
+ }
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log() {}
+async function sleep(ms = 0) { now += ms || 250; }
+async function humanPause() {}
+function simulateClick(el) {
+ clicks.push(el?.textContent || 'clicked');
+ promptIndex += 1;
+ location.href = 'https://chatgpt.com/';
+ document.body.innerText = promptIndex === 1
+ ? '你已准备就绪 继续'
+ : 'ChatGPT';
+}
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getActionText(el) { return el?.textContent || ''; }
+function getSignupAuthRetryPathPatterns() { return []; }
+function getAuthTimeoutErrorPageState() { return null; }
+async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); }
+function createSignupUserAlreadyExistsError() { return new Error('user already exists'); }
+function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); }
+function getStep5ErrorText() { return ''; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isStep5Ready() { return true; }
+function isLikelyLoggedInChatgptHomeUrl() { return location.href === 'https://chatgpt.com/'; }
+function isOAuthConsentPage() { return false; }
+function isAddPhonePageReady() { return false; }
+
+${extractFunction('isSignupProfilePageUrl')}
+${getStep5OutcomeBundle()}
+
+return {
+ run() {
+ return waitForStep5SubmitOutcome({ timeoutMs: 3000, maxSubmitClicks: 1 });
+ },
+ snapshot() {
+ return { clicks, now };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, {
+ state: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount: 2,
+ });
+ assert.deepStrictEqual(api.snapshot().clicks, ['跳过', '继续']);
+});
+
+test('step 5 does not treat profile create-account button as post-submit prompt', async () => {
+ const api = new Function(`
+const submitButton = {
+ textContent: '完成帐户创建',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const location = {
+ href: 'https://auth.openai.com/about-you',
+};
+const document = {
+ body: {
+ innerText: '告诉我们你的姓名和生日 完成帐户创建',
+ },
+ querySelector() { return submitButton; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return [submitButton];
+ }
+ return [];
+ },
+};
+
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getActionText(el) { return el?.textContent || ''; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isStep5Ready() { return true; }
+
+${extractFunction('isSignupProfilePageUrl')}
+${extractFunction('getStep5ProfilePathPatterns')}
+${extractFunction('isStep5ProfilePageUrl')}
+${extractFunction('isStep5ProfileStillVisible')}
+${extractFunction('findStep5PostSubmitOnboardingAction')}
+${extractFunction('isStep5PostSubmitOnboardingPage')}
+
+return {
+ run() {
+ return {
+ action: findStep5PostSubmitOnboardingAction(),
+ page: isStep5PostSubmitOnboardingPage({ allowProfileVisiblePrompt: true }),
+ };
+ },
+};
+`)();
+
+ const result = api.run();
+
+ assert.equal(result.action, null);
+ assert.equal(result.page, false);
+});
+
+test('step 5 post-submit prompt detection skips heavy page text scan when no action exists', () => {
+ const api = new Function(`
+let pageTextReads = 0;
+const location = {
+ href: 'https://chatgpt.com/',
+};
+const document = {
+ body: {
+ innerText: 'ChatGPT home with a very large conversation tree',
+ },
+ querySelector() { return null; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"]') return [];
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') return [];
+ return [];
+ },
+};
+
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getActionText(el) { return el?.textContent || ''; }
+function getPageTextSnapshot() {
+ pageTextReads += 1;
+ return document.body.innerText;
+}
+function isStep5Ready() { return false; }
+
+${extractFunction('isSignupProfilePageUrl')}
+${extractFunction('getStep5ProfilePathPatterns')}
+${extractFunction('isStep5ProfilePageUrl')}
+${extractFunction('isStep5ProfileStillVisible')}
+${extractFunction('findStep5PostSubmitOnboardingAction')}
+${extractFunction('isStep5PostSubmitOnboardingPage')}
+
+return {
+ run() {
+ return isStep5PostSubmitOnboardingPage({ allowProfileVisiblePrompt: true });
+ },
+ snapshot() {
+ return { pageTextReads };
+ },
+};
+`)();
+
+ assert.equal(api.run(), false);
+ assert.equal(api.snapshot().pageTextReads, 0);
+});
+
+test('step 5 clicks continue on post-submit ready page before completing', async () => {
+ const api = new Function(`
+let now = 0;
+const clicks = [];
+let promptIndex = 0;
+const continueButton = {
+ textContent: '继续',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const agreeButton = {
+ textContent: '同意',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const location = {
+ href: 'https://chatgpt.com/',
+};
+const document = {
+ body: {
+ innerText: '你已准备就绪 ChatGPT 可能会犯错。聊天可能会被审查并用于训练。了解你的选择 继续 继续操作即表示你同意我们的条款,并已阅读我们的隐私政策。',
+ },
+ querySelector() { return null; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return promptIndex === 0 ? [continueButton] : promptIndex === 1 ? [agreeButton] : [];
+ }
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log() {}
+async function sleep(ms = 0) { now += ms || 250; }
+async function humanPause() {}
+function simulateClick(el) {
+ clicks.push(el?.textContent || 'clicked');
+ promptIndex += 1;
+ document.body.innerText = promptIndex === 1
+ ? '继续操作即表示你同意我们的条款,并已阅读我们的隐私政策。同意'
+ : 'ChatGPT';
+}
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getActionText(el) { return el?.textContent || ''; }
+function getSignupAuthRetryPathPatterns() { return []; }
+function getAuthTimeoutErrorPageState() { return null; }
+async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); }
+function createSignupUserAlreadyExistsError() { return new Error('user already exists'); }
+function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); }
+function getStep5ErrorText() { return ''; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isStep5Ready() { return false; }
+function isLikelyLoggedInChatgptHomeUrl() { return document.body.innerText === 'ChatGPT'; }
+function isOAuthConsentPage() { return false; }
+function isAddPhonePageReady() { return false; }
+
+${extractFunction('isSignupProfilePageUrl')}
+${getStep5OutcomeBundle()}
+
+return {
+ run() {
+ return waitForStep5SubmitOutcome({ timeoutMs: 3000 });
+ },
+ snapshot() {
+ return { clicks, now };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, {
+ state: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount: 2,
+ });
+ assert.deepStrictEqual(api.snapshot().clicks, ['继续', '同意']);
+});
+
+test('step 5 completes after two successful post-submit home page prompt actions', async () => {
+ const api = new Function(`
+let now = 0;
+const clicks = [];
+let promptIndex = 0;
+const buttons = [
+ {
+ textContent: '跳过',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+ },
+ {
+ textContent: '同意',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+ },
+];
+const location = {
+ href: 'https://chatgpt.com/',
+};
+const document = {
+ body: {
+ innerText: '是什么促使你使用 ChatGPT? 我们会利用这些信息提出一些可能会对你有用的建议。 学校 工作 个人任务 乐趣和娱乐 其他 下一步 跳过',
+ },
+ querySelector() { return null; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return promptIndex < buttons.length ? [buttons[promptIndex]] : [];
+ }
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log() {}
+async function sleep(ms = 0) { now += ms || 250; }
+async function humanPause() {}
+function simulateClick(el) {
+ clicks.push(el?.textContent || 'clicked');
+ promptIndex += 1;
+ document.body.innerText = promptIndex === 1
+ ? '继续操作即表示你同意我们的条款,并已阅读我们的隐私政策。同意'
+ : 'ChatGPT';
+}
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getActionText(el) { return el?.textContent || ''; }
+function getSignupAuthRetryPathPatterns() { return []; }
+function getAuthTimeoutErrorPageState() { return null; }
+async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); }
+function createSignupUserAlreadyExistsError() { return new Error('user already exists'); }
+function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); }
+function getStep5ErrorText() { return ''; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isStep5Ready() { return false; }
+function isLikelyLoggedInChatgptHomeUrl() { return location.href === 'https://chatgpt.com/'; }
+function isOAuthConsentPage() { return false; }
+function isAddPhonePageReady() { return false; }
+
+${extractFunction('isSignupProfilePageUrl')}
+${getStep5OutcomeBundle()}
+
+return {
+ run() {
+ return waitForStep5SubmitOutcome({ timeoutMs: 3000 });
+ },
+ snapshot() {
+ return { clicks, now };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, {
+ state: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount: 2,
+ });
+ assert.deepStrictEqual(api.snapshot().clicks, ['跳过', '同意']);
+});
+
+test('step 5 directly clicks Chinese survey buttons by button text', async () => {
+ const api = new Function(`
+let now = 0;
+const clicks = [];
+let promptIndex = 0;
+const makeButton = (text) => ({
+ tagName: 'BUTTON',
+ textContent: text,
+ innerText: text,
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+});
+const promptButtons = [
+ [makeButton('学校'), makeButton('工作'), makeButton('个人任务'), makeButton('乐趣和娱乐'), makeButton('其他'), makeButton('下一步'), makeButton('跳过')],
+ [makeButton('继续')],
+];
+const location = {
+ href: 'https://chatgpt.com/',
+};
+const document = {
+ body: {
+ innerText: '是什么促使你使用 ChatGPT? 我们会利用这些信息提出一些可能会对你有用的建议。 学校 工作 个人任务 乐趣和娱乐 其他 下一步 跳过',
+ },
+ querySelector() { return null; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"]') {
+ return promptButtons[promptIndex] || [];
+ }
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return promptButtons[promptIndex] || [];
+ }
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log() {}
+async function sleep(ms = 0) { now += ms || 250; }
+async function humanPause() {}
+function simulateClick(el) {
+ clicks.push(el?.innerText || el?.textContent || 'clicked');
+ promptIndex += 1;
+ document.body.innerText = promptIndex === 1
+ ? '你已准备就绪 继续 继续操作即表示你同意我们的条款,并已阅读我们的隐私政策。'
+ : 'ChatGPT';
+}
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getSignupAuthRetryPathPatterns() { return []; }
+function getAuthTimeoutErrorPageState() { return null; }
+async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); }
+function createSignupUserAlreadyExistsError() { return new Error('user already exists'); }
+function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); }
+function getStep5ErrorText() { return ''; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isStep5Ready() { return false; }
+function isLikelyLoggedInChatgptHomeUrl() { return location.href === 'https://chatgpt.com/'; }
+function isOAuthConsentPage() { return false; }
+function isAddPhonePageReady() { return false; }
+
+${extractFunction('getActionText')}
+${extractFunction('isSignupProfilePageUrl')}
+${getStep5OutcomeBundle()}
+
+return {
+ run() {
+ return waitForStep5SubmitOutcome({ timeoutMs: 3000 });
+ },
+ snapshot() {
+ return { clicks, now };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, {
+ state: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount: 2,
+ });
+ assert.deepStrictEqual(api.snapshot().clicks, ['跳过', '继续']);
+});
+
+test('step 5 completes after all available post-submit prompt button clicks up to the limit', async () => {
+ const api = new Function(`
+let now = 0;
+const clicks = [];
+let promptIndex = 0;
+const makeButton = (text) => ({
+ tagName: 'BUTTON',
+ textContent: text,
+ innerText: text,
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+});
+const promptButtons = [
+ [makeButton('跳过')],
+ [makeButton('继续')],
+ [makeButton('同意')],
+ [makeButton('继续')],
+];
+const promptTexts = [
+ '是什么促使你使用 ChatGPT? 跳过',
+ '你已准备就绪 继续',
+ '继续操作即表示你同意我们的条款,并已阅读我们的隐私政策。同意',
+ '你已准备就绪 继续',
+];
+const location = {
+ href: 'https://chatgpt.com/',
+};
+const document = {
+ body: {
+ innerText: promptTexts[0],
+ },
+ querySelector() { return null; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"]') {
+ return promptButtons[promptIndex] || [];
+ }
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return promptButtons[promptIndex] || [];
+ }
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log() {}
+async function sleep(ms = 0) { now += ms || 250; }
+async function humanPause() {}
+function simulateClick(el) {
+ clicks.push(el?.innerText || el?.textContent || 'clicked');
+ promptIndex += 1;
+ document.body.innerText = promptTexts[promptIndex] || 'still waiting';
+}
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getSignupAuthRetryPathPatterns() { return []; }
+function getAuthTimeoutErrorPageState() { return null; }
+async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); }
+function createSignupUserAlreadyExistsError() { return new Error('user already exists'); }
+function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); }
+function getStep5ErrorText() { return ''; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isStep5Ready() { return false; }
+function isLikelyLoggedInChatgptHomeUrl() { return false; }
+function isOAuthConsentPage() { return false; }
+function isAddPhonePageReady() { return false; }
+
+${extractFunction('getActionText')}
+${extractFunction('isSignupProfilePageUrl')}
+${getStep5OutcomeBundle()}
+
+return {
+ run() {
+ return waitForStep5SubmitOutcome({ timeoutMs: 3000 });
+ },
+ snapshot() {
+ return { clicks, now };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, {
+ state: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount: 3,
+ });
+ assert.deepStrictEqual(api.snapshot().clicks, ['跳过', '继续', '同意']);
+});
+
+test('step 5 clicks agree on post-submit terms prompt before completing', async () => {
+ const api = new Function(`
+let now = 0;
+const clicks = [];
+let promptIndex = 0;
+const agreeButton = {
+ textContent: '同意',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const continueButton = {
+ textContent: '继续',
+ hidden: false,
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+};
+const location = {
+ href: 'https://chatgpt.com/',
+};
+const document = {
+ body: {
+ innerText: '继续操作即表示你同意我们的条款,并已阅读我们的隐私政策。同意',
+ },
+ querySelector() { return null; },
+ querySelectorAll(selector) {
+ if (selector === 'button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]') {
+ return promptIndex === 0 ? [agreeButton] : promptIndex === 1 ? [continueButton] : [];
+ }
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log() {}
+async function sleep(ms = 0) { now += ms || 250; }
+async function humanPause() {}
+function simulateClick(el) {
+ clicks.push(el?.textContent || 'clicked');
+ promptIndex += 1;
+ document.body.innerText = promptIndex === 1
+ ? '你已准备就绪 继续'
+ : 'ChatGPT';
+}
+function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; }
+function getActionText(el) { return el?.textContent || ''; }
+function getSignupAuthRetryPathPatterns() { return []; }
+function getAuthTimeoutErrorPageState() { return null; }
+async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); }
+function createSignupUserAlreadyExistsError() { return new Error('user already exists'); }
+function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); }
+function getStep5ErrorText() { return ''; }
+function getPageTextSnapshot() { return document.body.innerText; }
+function isStep5Ready() { return false; }
+function isLikelyLoggedInChatgptHomeUrl() { return document.body.innerText === 'ChatGPT'; }
+function isOAuthConsentPage() { return false; }
+function isAddPhonePageReady() { return false; }
+
+${extractFunction('isSignupProfilePageUrl')}
+${getStep5OutcomeBundle()}
+
+return {
+ run() {
+ return waitForStep5SubmitOutcome({ timeoutMs: 3000 });
+ },
+ snapshot() {
+ return { clicks, now };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, {
+ state: 'logged_in_home',
+ url: 'https://chatgpt.com/',
+ postSubmitPromptActionsCompleted: true,
+ postSubmitPromptActionCount: 2,
+ });
+ assert.deepStrictEqual(api.snapshot().clicks, ['同意', '继续']);
+});
+
test('step 5 does not treat unknown auth page as left_profile success', () => {
const api = new Function(`
const location = {
@@ -1165,4 +1917,5 @@ return {
const result = api.run();
assert.equal(result.completionCount, 0);
assert.equal(result.events.some((entry) => entry.type === 'log' && /检测到页面开始导航/.test(entry.message)), true);
+ assert.equal(result.events.some((entry) => entry.type === 'log' && /检测到页面开始导航/.test(entry.message) && entry.level === 'warn'), false);
});