From 6ae4e0f5a0394730aa55d9386a13f66886279e76 Mon Sep 17 00:00:00 2001
From: initiatione <269353933@qq.com>
Date: Fri, 1 May 2026 11:41:12 +0800
Subject: [PATCH] Add OAuth flow timeout toggle
---
background.js | 17 ++
background/message-router.js | 6 +
background/steps/confirm-oauth.js | 54 ++++-
sidepanel/sidepanel.html | 14 ++
sidepanel/sidepanel.js | 17 ++
tests/background-auth-chain-guard.test.js | 96 +++++++++
...und-cloudflare-temp-email-settings.test.js | 78 ++++++++
tests/sidepanel-contribution-mode.test.js | 1 +
tests/sidepanel-icloud-provider.test.js | 2 +
tests/sidepanel-mail2925-base-email.test.js | 1 +
...epanel-phone-verification-settings.test.js | 5 +
tests/step9-oauth-timeout-toggle.test.js | 184 ++++++++++++++++++
tests/step9-timeout-recovery.test.js | 10 +-
13 files changed, 476 insertions(+), 9 deletions(-)
create mode 100644 tests/step9-oauth-timeout-toggle.test.js
diff --git a/background.js b/background.js
index 271edeb..01bb275 100644
--- a/background.js
+++ b/background.js
@@ -490,6 +490,7 @@ const PERSISTED_SETTING_DEFAULTS = {
currentPayPalAccountId: '',
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
+ oauthFlowTimeoutEnabled: true,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
@@ -1466,6 +1467,7 @@ function normalizePersistentSettingValue(key, value) {
case 'plusPaymentMethod':
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
case 'autoRunSkipFailures':
+ case 'oauthFlowTimeoutEnabled':
case 'autoRunDelayEnabled':
case 'phoneVerificationEnabled':
case 'plusModeEnabled':
@@ -8915,6 +8917,7 @@ const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.crea
DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS,
DEFAULT_PHONE_CODE_POLL_ROUNDS,
ensureStep8SignupPageReady,
+ getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs,
getState,
HERO_SMS_COUNTRY_ID,
@@ -9670,6 +9673,16 @@ function normalizeOAuthFlowSourceUrl(value) {
async function startOAuthFlowTimeoutWindow(options = {}) {
const step = Number(options.step) || 7;
+ const state = options.state || await getState();
+ if (state?.oauthFlowTimeoutEnabled === false) {
+ await setState({
+ oauthFlowDeadlineAt: null,
+ oauthFlowDeadlineSourceUrl: null,
+ });
+ await addLog(`步骤 ${step}:已拿到新的 OAuth 登录地址,授权后链总超时已关闭,仅保留各步骤本地等待超时。`, 'info');
+ return null;
+ }
+
const deadlineAt = Date.now() + OAUTH_FLOW_TIMEOUT_MS;
await setState({
oauthFlowDeadlineAt: deadlineAt,
@@ -9683,6 +9696,10 @@ async function getOAuthFlowRemainingMs(options = {}) {
const step = Number(options.step) || 7;
const actionLabel = String(options.actionLabel || '后续授权流程').trim() || '后续授权流程';
const state = options.state || await getState();
+ if (state?.oauthFlowTimeoutEnabled === false) {
+ return null;
+ }
+
const deadlineAt = normalizeOAuthFlowDeadlineAt(state?.oauthFlowDeadlineAt);
const deadlineSourceUrl = normalizeOAuthFlowSourceUrl(state?.oauthFlowDeadlineSourceUrl);
const currentOauthUrl = normalizeOAuthFlowSourceUrl(options.oauthUrl !== undefined ? options.oauthUrl : state?.oauthUrl);
diff --git a/background/message-router.js b/background/message-router.js
index 21f1c6b..3bb81d7 100644
--- a/background/message-router.js
+++ b/background/message-router.js
@@ -705,10 +705,16 @@
? Boolean(updates.plusModeEnabled)
: Boolean(currentState?.plusModeEnabled);
const stepModeChanged = modeChanged || (nextPlusModeEnabled && plusPaymentChanged);
+ const oauthFlowTimeoutDisabled = Object.prototype.hasOwnProperty.call(updates, 'oauthFlowTimeoutEnabled')
+ && updates.oauthFlowTimeoutEnabled === false;
await setPersistentSettings(updates);
const stateUpdates = {
...updates,
...sessionUpdates,
+ ...(oauthFlowTimeoutDisabled ? {
+ oauthFlowDeadlineAt: null,
+ oauthFlowDeadlineSourceUrl: null,
+ } : {}),
};
if (stepModeChanged && typeof getStepIdsForState === 'function') {
const nextStateForSteps = { ...currentState, ...stateUpdates };
diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js
index e172945..37d9cd9 100644
--- a/background/steps/confirm-oauth.js
+++ b/background/steps/confirm-oauth.js
@@ -9,6 +9,7 @@
clickWithDebugger,
completeStepFromBackground,
ensureStep8SignupPageReady,
+ getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs,
getStep8CallbackUrlFromNavigation,
getStep8CallbackUrlFromTabUpdate,
@@ -34,6 +35,9 @@
setStep8TabUpdatedListener,
} = deps;
+ const LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS = 240000;
+ const CALLBACK_TIMEOUT_CHECK_INTERVAL_MS = 1000;
+
function getVisibleStep(state, fallback = 9) {
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
return visibleStep > 0 ? visibleStep : fallback;
@@ -54,17 +58,17 @@
await addLog(`步骤 ${visibleStep}:正在监听 localhost 回调地址...`);
- let callbackTimeoutMs = 240000;
+ let callbackTimeoutMs = LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS;
let timeoutRecoveryAttempted = false;
while (true) {
try {
callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
- ? await getOAuthFlowStepTimeoutMs(240000, {
+ ? await getOAuthFlowStepTimeoutMs(LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS, {
step: visibleStep,
actionLabel: 'OAuth localhost 回调',
oauthUrl: activeState?.oauthUrl || '',
})
- : 240000;
+ : LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS;
break;
} catch (error) {
if (timeoutRecoveryAttempted || typeof recoverOAuthLocalhostTimeout !== 'function') {
@@ -86,8 +90,14 @@
return new Promise((resolve, reject) => {
let resolved = false;
let signupTabId = null;
+ const callbackWaitStartedAt = Date.now();
+ let timeoutCheckTimer = null;
const cleanupListener = () => {
+ if (timeoutCheckTimer) {
+ clearTimeout(timeoutCheckTimer);
+ timeoutCheckTimer = null;
+ }
cleanupStep8NavigationListeners();
setStep8PendingReject(null);
};
@@ -95,7 +105,6 @@
const rejectStep9 = (error) => {
if (resolved) return;
resolved = true;
- clearTimeout(timeout);
cleanupListener();
reject(error);
};
@@ -105,7 +114,6 @@
resolved = true;
cleanupListener();
- clearTimeout(timeout);
addLog(`步骤 ${visibleStep}:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl });
@@ -116,9 +124,39 @@
});
};
- const timeout = setTimeout(() => {
- rejectStep9(new Error(`${Math.round(callbackTimeoutMs / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`));
- }, callbackTimeoutMs);
+ const checkCallbackTimeout = async () => {
+ if (resolved) {
+ return;
+ }
+ const elapsedMs = Date.now() - callbackWaitStartedAt;
+ if (elapsedMs >= LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS) {
+ rejectStep9(new Error(`${Math.round(LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`));
+ return;
+ }
+
+ if (typeof getOAuthFlowRemainingMs === 'function') {
+ try {
+ await getOAuthFlowRemainingMs({
+ step: visibleStep,
+ actionLabel: 'OAuth localhost 回调',
+ oauthUrl: activeState?.oauthUrl || '',
+ });
+ } catch (error) {
+ rejectStep9(error);
+ return;
+ }
+ } else if (elapsedMs >= callbackTimeoutMs) {
+ rejectStep9(new Error(`${Math.round(callbackTimeoutMs / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`));
+ return;
+ }
+
+ timeoutCheckTimer = setTimeout(checkCallbackTimeout, CALLBACK_TIMEOUT_CHECK_INTERVAL_MS);
+ };
+
+ timeoutCheckTimer = setTimeout(
+ checkCallbackTimeout,
+ Math.min(CALLBACK_TIMEOUT_CHECK_INTERVAL_MS, Math.max(1, callbackTimeoutMs))
+ );
setStep8PendingReject((error) => {
rejectStep9(error);
diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html
index 0443be8..87eca16 100644
--- a/sidepanel/sidepanel.html
+++ b/sidepanel/sidepanel.html
@@ -394,6 +394,20 @@
+
+
授权总超时
+
+
+ 关闭后只取消 Step 7 后链总预算
+
+
OAuth
等待中...
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index b5d8e29..f9748f8 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -288,6 +288,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = document.getElementById('inpu
const inputAutoDelayEnabled = document.getElementById('input-auto-delay-enabled');
const inputAutoDelayMinutes = document.getElementById('input-auto-delay-minutes');
const inputAutoStepDelaySeconds = document.getElementById('input-auto-step-delay-seconds');
+const inputOAuthFlowTimeoutEnabled = document.getElementById('input-oauth-flow-timeout-enabled');
const inputVerificationResendCount = document.getElementById('input-verification-resend-count');
const rowPhoneVerificationEnabled = document.getElementById('row-phone-verification-enabled');
const btnTogglePhoneVerificationSection = document.getElementById('btn-toggle-phone-verification-section');
@@ -2650,6 +2651,9 @@ function collectSettingsPayload() {
autoRunDelayEnabled: inputAutoDelayEnabled.checked,
autoRunDelayMinutes: normalizeAutoDelayMinutes(inputAutoDelayMinutes.value),
autoStepDelaySeconds: normalizeAutoStepDelaySeconds(inputAutoStepDelaySeconds.value),
+ oauthFlowTimeoutEnabled: inputOAuthFlowTimeoutEnabled
+ ? Boolean(inputOAuthFlowTimeoutEnabled.checked)
+ : true,
phoneVerificationEnabled: Boolean(inputPhoneVerificationEnabled?.checked),
verificationResendCount: normalizeVerificationResendCount(
inputVerificationResendCount?.value,
@@ -4202,6 +4206,11 @@ function applySettingsState(state) {
inputAutoDelayEnabled.checked = Boolean(state?.autoRunDelayEnabled);
inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state?.autoRunDelayMinutes));
inputAutoStepDelaySeconds.value = formatAutoStepDelayInputValue(state?.autoStepDelaySeconds);
+ if (inputOAuthFlowTimeoutEnabled) {
+ inputOAuthFlowTimeoutEnabled.checked = state?.oauthFlowTimeoutEnabled !== undefined
+ ? Boolean(state.oauthFlowTimeoutEnabled)
+ : true;
+ }
if (inputVerificationResendCount) {
const restoredVerificationResendCount = state?.verificationResendCount !== undefined
? state.verificationResendCount
@@ -7765,6 +7774,11 @@ inputAutoDelayMinutes.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
+inputOAuthFlowTimeoutEnabled?.addEventListener('change', () => {
+ markSettingsDirty(true);
+ saveSettings({ silent: true }).catch(() => { });
+});
+
inputPhoneVerificationEnabled?.addEventListener('change', () => {
if (inputPhoneVerificationEnabled.checked) {
setPhoneVerificationSectionExpanded(true);
@@ -8357,6 +8371,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.payload.autoStepDelaySeconds !== undefined) {
inputAutoStepDelaySeconds.value = formatAutoStepDelayInputValue(message.payload.autoStepDelaySeconds);
}
+ if (message.payload.oauthFlowTimeoutEnabled !== undefined && inputOAuthFlowTimeoutEnabled) {
+ inputOAuthFlowTimeoutEnabled.checked = Boolean(message.payload.oauthFlowTimeoutEnabled);
+ }
if (
(
message.payload.verificationResendCount !== undefined
diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js
index c0a6356..9f251d2 100644
--- a/tests/background-auth-chain-guard.test.js
+++ b/tests/background-auth-chain-guard.test.js
@@ -296,3 +296,99 @@ return {
assert.equal(timeoutMs, 15000);
});
+
+test('oauth timeout budget clamps local timeout when enabled by default', async () => {
+ const api = new Function(`
+const LOG_PREFIX = '[test]';
+const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
+${extractFunction('buildOAuthFlowTimeoutError')}
+${extractFunction('normalizeOAuthFlowDeadlineAt')}
+${extractFunction('normalizeOAuthFlowSourceUrl')}
+${extractFunction('getOAuthFlowRemainingMs')}
+${extractFunction('getOAuthFlowStepTimeoutMs')}
+return {
+ getOAuthFlowStepTimeoutMs,
+};
+`)();
+
+ const timeoutMs = await api.getOAuthFlowStepTimeoutMs(15000, {
+ step: 8,
+ actionLabel: '登录验证码流程',
+ state: {
+ oauthUrl: 'https://oauth.example/current',
+ oauthFlowDeadlineAt: Date.now() + 1200,
+ oauthFlowDeadlineSourceUrl: 'https://oauth.example/current',
+ },
+ });
+
+ assert(timeoutMs <= 1200);
+ assert(timeoutMs >= 1000);
+});
+
+test('oauth timeout budget disabled mode ignores active deadlines', async () => {
+ const api = new Function(`
+const LOG_PREFIX = '[test]';
+const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
+${extractFunction('buildOAuthFlowTimeoutError')}
+${extractFunction('normalizeOAuthFlowDeadlineAt')}
+${extractFunction('normalizeOAuthFlowSourceUrl')}
+${extractFunction('getOAuthFlowRemainingMs')}
+${extractFunction('getOAuthFlowStepTimeoutMs')}
+return {
+ getOAuthFlowStepTimeoutMs,
+};
+`)();
+
+ const timeoutMs = await api.getOAuthFlowStepTimeoutMs(15000, {
+ step: 9,
+ actionLabel: 'OAuth localhost 回调',
+ state: {
+ oauthFlowTimeoutEnabled: false,
+ oauthUrl: 'https://oauth.example/current',
+ oauthFlowDeadlineAt: Date.now() - 1000,
+ oauthFlowDeadlineSourceUrl: 'https://oauth.example/current',
+ },
+ });
+
+ assert.equal(timeoutMs, 15000);
+});
+
+test('startOAuthFlowTimeoutWindow clears stale deadline when timeout is disabled', async () => {
+ const events = {
+ stateUpdates: [],
+ logs: [],
+ };
+ const api = new Function('events', `
+const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
+async function getState() {
+ return {
+ oauthFlowTimeoutEnabled: false,
+ oauthFlowDeadlineAt: Date.now() - 1000,
+ oauthFlowDeadlineSourceUrl: 'https://oauth.example/old',
+ };
+}
+async function setState(update) {
+ events.stateUpdates.push(update);
+}
+async function addLog(message, level) {
+ events.logs.push({ message, level });
+}
+${extractFunction('normalizeOAuthFlowSourceUrl')}
+${extractFunction('startOAuthFlowTimeoutWindow')}
+return {
+ startOAuthFlowTimeoutWindow,
+};
+`)(events);
+
+ const result = await api.startOAuthFlowTimeoutWindow({
+ step: 7,
+ oauthUrl: 'https://oauth.example/current',
+ });
+
+ assert.equal(result, null);
+ assert.deepStrictEqual(events.stateUpdates, [{
+ oauthFlowDeadlineAt: null,
+ oauthFlowDeadlineSourceUrl: null,
+ }]);
+ assert.match(events.logs[0].message, /授权后链总超时已关闭/);
+});
diff --git a/tests/background-cloudflare-temp-email-settings.test.js b/tests/background-cloudflare-temp-email-settings.test.js
index e44404f..c58f5b5 100644
--- a/tests/background-cloudflare-temp-email-settings.test.js
+++ b/tests/background-cloudflare-temp-email-settings.test.js
@@ -143,3 +143,81 @@ return {
domains: ['mail.example.com'],
});
});
+
+test('oauth flow timeout setting is persisted and normalized as boolean', () => {
+ const api = new Function(`
+const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
+const PERSISTED_SETTING_DEFAULTS = {
+ panelMode: 'cpa',
+ oauthFlowTimeoutEnabled: true,
+ autoStepDelaySeconds: null,
+ verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT,
+ mailProvider: '163',
+ mail2925Mode: 'provide',
+ emailGenerator: 'duck',
+ autoDeleteUsedIcloudAlias: false,
+ accountRunHistoryTextEnabled: false,
+ cloudflareTempEmailUseRandomSubdomain: false,
+ cloudflareTempEmailDomain: '',
+ cloudflareTempEmailDomains: [],
+};
+const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
+function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
+function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
+function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
+function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
+function normalizeAutoStepDelaySeconds(value, fallback = null) { return value == null || value === '' ? fallback : Number(value); }
+function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
+function normalizeMailProvider(value) { return String(value || '').trim().toLowerCase() || '163'; }
+function normalizeMail2925Mode(value) { return String(value || '').trim().toLowerCase() === 'receive' ? 'receive' : 'provide'; }
+function normalizeEmailGenerator(value) { return String(value || '').trim().toLowerCase() || 'duck'; }
+function normalizeIcloudHost(value) { return ''; }
+function normalizeIcloudTargetMailboxType(value) { return String(value || '').trim() || 'icloud-inbox'; }
+function normalizeIcloudForwardMailProvider(value) { return String(value || '').trim() || 'qq'; }
+function normalizeIcloudFetchMode(value) { return String(value || '').trim() === 'always_new' ? 'always_new' : 'reuse_existing'; }
+function normalizeAccountRunHistoryHelperBaseUrl(value) { return String(value || '').trim(); }
+function normalizeHotmailServiceMode(value) { return String(value || '').trim().toLowerCase() === 'remote' ? 'remote' : 'local'; }
+function normalizeHotmailRemoteBaseUrl(value) { return String(value || '').trim(); }
+function normalizeHotmailLocalBaseUrl(value) { return String(value || '').trim(); }
+function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); }
+function normalizeLuckmailEmailType(value) { return String(value || '').trim(); }
+function normalizeCloudflareDomain(value) { return String(value || '').trim().toLowerCase(); }
+function normalizeCloudflareDomains(value) { return Array.isArray(value) ? value.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; }
+function normalizeCustomEmailPool(value) { return Array.isArray(value) ? value : []; }
+function normalizeCloudflareTempEmailBaseUrl(value) { return String(value || '').trim(); }
+function normalizeCloudflareTempEmailAddress(value = '') { return String(value || '').trim().toLowerCase(); }
+function normalizeCloudflareTempEmailReceiveMailbox(value = '') { return String(value || '').trim().toLowerCase(); }
+function normalizeCloudflareTempEmailDomain(value) { return String(value || '').trim().toLowerCase(); }
+function normalizeCloudflareTempEmailDomains(value) { return Array.isArray(value) ? value.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; }
+function normalizeHotmailAccounts(value) { return Array.isArray(value) ? value : []; }
+function normalizeMail2925Accounts(value) { return Array.isArray(value) ? value : []; }
+function normalizePayPalAccounts(value) { return Array.isArray(value) ? value : []; }
+function normalizeHeroSmsAcquirePriority(value) { return String(value || '').trim() === 'price' ? 'price' : 'country'; }
+function normalizeHeroSmsMaxPrice(value) { return String(value || '').trim(); }
+function normalizeHeroSmsCountryFallback(value) { return Array.isArray(value) ? value : []; }
+function normalizePhoneVerificationReplacementLimit(value) { return Number(value) || 3; }
+function normalizePhoneCodeWaitSeconds(value) { return Number(value) || 60; }
+function normalizePhoneCodeTimeoutWindows(value) { return Number(value) || 2; }
+function normalizePhoneCodePollIntervalSeconds(value) { return Number(value) || 5; }
+function normalizePhoneCodePollMaxRounds(value) { return Number(value) || 4; }
+function resolveLegacyAutoStepDelaySeconds() { return undefined; }
+${extractFunction('normalizePersistentSettingValue')}
+${extractFunction('buildPersistentSettingsPayload')}
+return {
+ buildPersistentSettingsPayload,
+ normalizePersistentSettingValue,
+};
+ `)();
+
+ assert.equal(api.normalizePersistentSettingValue('oauthFlowTimeoutEnabled', 0), false);
+ assert.equal(api.normalizePersistentSettingValue('oauthFlowTimeoutEnabled', 1), true);
+
+ assert.deepEqual(api.buildPersistentSettingsPayload({
+ oauthFlowTimeoutEnabled: false,
+ }), {
+ oauthFlowTimeoutEnabled: false,
+ });
+
+ const defaults = api.buildPersistentSettingsPayload({}, { fillDefaults: true });
+ assert.equal(defaults.oauthFlowTimeoutEnabled, true);
+});
diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js
index 4d99008..70141d9 100644
--- a/tests/sidepanel-contribution-mode.test.js
+++ b/tests/sidepanel-contribution-mode.test.js
@@ -220,6 +220,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' };
const inputAutoDelayEnabled = { checked: true };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '10' };
+const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '6' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
diff --git a/tests/sidepanel-icloud-provider.test.js b/tests/sidepanel-icloud-provider.test.js
index d6a5c98..39aacf3 100644
--- a/tests/sidepanel-icloud-provider.test.js
+++ b/tests/sidepanel-icloud-provider.test.js
@@ -128,6 +128,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
+const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
@@ -375,6 +376,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '' };
const inputAutoStepDelaySeconds = { value: '' };
+const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '' };
const inputPhoneVerificationEnabled = { checked: false };
const DEFAULT_PHONE_VERIFICATION_ENABLED = false;
diff --git a/tests/sidepanel-mail2925-base-email.test.js b/tests/sidepanel-mail2925-base-email.test.js
index 0b51963..a66515c 100644
--- a/tests/sidepanel-mail2925-base-email.test.js
+++ b/tests/sidepanel-mail2925-base-email.test.js
@@ -199,6 +199,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
+const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js
index fb280bd..cd7c5c1 100644
--- a/tests/sidepanel-phone-verification-settings.test.js
+++ b/tests/sidepanel-phone-verification-settings.test.js
@@ -77,6 +77,9 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.match(html, /id="row-phone-code-timeout-windows"/);
assert.match(html, /id="row-phone-code-poll-interval-seconds"/);
assert.match(html, /id="row-phone-code-poll-max-rounds"/);
+ assert.match(html, /id="row-oauth-flow-timeout"/);
+ assert.match(html, /id="input-oauth-flow-timeout-enabled"/);
+ assert.match(html, /只取消 Step 7 后链总预算/);
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
});
@@ -222,6 +225,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
+const inputOAuthFlowTimeoutEnabled = { checked: false };
const inputPhoneVerificationEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const inputHeroSmsApiKey = { value: 'demo-key' };
@@ -299,6 +303,7 @@ return { collectSettingsPayload };
const payload = api.collectSettingsPayload();
assert.equal(payload.phoneVerificationEnabled, true);
+ assert.equal(payload.oauthFlowTimeoutEnabled, false);
assert.equal(payload.accountRunHistoryTextEnabled, true);
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
assert.equal(payload.heroSmsApiKey, 'demo-key');
diff --git a/tests/step9-oauth-timeout-toggle.test.js b/tests/step9-oauth-timeout-toggle.test.js
new file mode 100644
index 0000000..00803a1
--- /dev/null
+++ b/tests/step9-oauth-timeout-toggle.test.js
@@ -0,0 +1,184 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const step9ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8');
+
+test('step9 observes disabled oauth timeout while waiting for localhost callback', async () => {
+ const api = new Function('step9ModuleSource', `
+const self = {};
+let webNavListener = null;
+let webNavCommittedListener = null;
+let step8TabUpdatedListener = null;
+let step8PendingReject = null;
+let cleanupCalls = 0;
+let remainingCalls = 0;
+let recoveryCalls = 0;
+let completePayload = null;
+const callbackUrl = 'http://localhost:1455/auth/callback?code=abc&state=xyz';
+
+const chrome = {
+ webNavigation: {
+ onBeforeNavigate: {
+ addListener(listener) {
+ webNavListener = listener;
+ setTimeout(() => {
+ if (typeof webNavListener === 'function') {
+ webNavListener({ tabId: 123, url: callbackUrl });
+ }
+ }, 25);
+ },
+ removeListener() {},
+ },
+ onCommitted: {
+ addListener(listener) {
+ webNavCommittedListener = listener;
+ },
+ removeListener() {},
+ },
+ },
+ tabs: {
+ onUpdated: {
+ addListener(listener) {
+ step8TabUpdatedListener = listener;
+ },
+ removeListener() {},
+ },
+ async update() {},
+ },
+};
+
+async function addLog() {}
+function cleanupStep8NavigationListeners() {
+ cleanupCalls += 1;
+ webNavListener = null;
+ webNavCommittedListener = null;
+ step8TabUpdatedListener = null;
+}
+function throwIfStep8SettledOrStopped(resolved) {
+ if (resolved) throw new Error('already resolved');
+}
+async function getTabId() { return 123; }
+async function isTabAlive() { return true; }
+async function ensureStep8SignupPageReady() {}
+async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) {
+ if (options.actionLabel === 'OAuth localhost 回调') {
+ return 1;
+ }
+ return defaultTimeoutMs;
+}
+async function getOAuthFlowRemainingMs() {
+ remainingCalls += 1;
+ return null;
+}
+async function recoverOAuthLocalhostTimeout() {
+ recoveryCalls += 1;
+ throw new Error('should not recover when timeout has been disabled');
+}
+async function waitForStep8Ready() {
+ return {
+ consentReady: true,
+ url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
+ };
+}
+async function triggerStep8ContentStrategy() {}
+async function waitForStep8ClickEffect() {
+ return { progressed: true, reason: 'url_changed', url: 'https://chatgpt.com/' };
+}
+function getStep8CallbackUrlFromNavigation(details, signupTabId) {
+ return Number(details?.tabId) === Number(signupTabId) ? details.url : '';
+}
+function getStep8CallbackUrlFromTabUpdate() { return ''; }
+function getStep8EffectLabel() { return 'URL 已变化'; }
+async function prepareStep8DebuggerClick() { return { rect: { centerX: 10, centerY: 10 } }; }
+async function clickWithDebugger() {}
+async function reloadStep8ConsentPage() {}
+async function reuseOrCreateTab() { return 123; }
+async function sleepWithStop() {}
+function setWebNavListener(listener) { webNavListener = listener; }
+function getWebNavListener() { return webNavListener; }
+function setWebNavCommittedListener(listener) { webNavCommittedListener = listener; }
+function getWebNavCommittedListener() { return webNavCommittedListener; }
+function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; }
+function getStep8TabUpdatedListener() { return step8TabUpdatedListener; }
+function setStep8PendingReject(handler) { step8PendingReject = handler; }
+async function completeStepFromBackground(step, payload) {
+ completePayload = { step, payload };
+}
+
+const STEP8_CLICK_RETRY_DELAY_MS = 1;
+const STEP8_READY_WAIT_TIMEOUT_MS = 5;
+const STEP8_MAX_ROUNDS = 1;
+const STEP8_STRATEGIES = [
+ { mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' },
+];
+
+${step9ModuleSource}
+
+const executor = self.MultiPageBackgroundStep9.createStep9Executor({
+ addLog,
+ chrome,
+ cleanupStep8NavigationListeners,
+ clickWithDebugger,
+ completeStepFromBackground,
+ ensureStep8SignupPageReady,
+ getOAuthFlowRemainingMs,
+ getOAuthFlowStepTimeoutMs,
+ getStep8CallbackUrlFromNavigation,
+ getStep8CallbackUrlFromTabUpdate,
+ getStep8EffectLabel,
+ getTabId,
+ getWebNavCommittedListener,
+ getWebNavListener,
+ getStep8TabUpdatedListener,
+ isTabAlive,
+ prepareStep8DebuggerClick,
+ recoverOAuthLocalhostTimeout,
+ reloadStep8ConsentPage,
+ reuseOrCreateTab,
+ setStep8PendingReject,
+ setStep8TabUpdatedListener,
+ setWebNavCommittedListener,
+ setWebNavListener,
+ sleepWithStop,
+ STEP8_CLICK_RETRY_DELAY_MS,
+ STEP8_MAX_ROUNDS,
+ STEP8_READY_WAIT_TIMEOUT_MS,
+ STEP8_STRATEGIES,
+ throwIfStep8SettledOrStopped,
+ triggerStep8ContentStrategy,
+ waitForStep8ClickEffect,
+ waitForStep8Ready,
+});
+
+return {
+ executeStep9: executor.executeStep9,
+ snapshot() {
+ return {
+ cleanupCalls,
+ remainingCalls,
+ recoveryCalls,
+ completePayload,
+ hasPendingReject: Boolean(step8PendingReject),
+ };
+ },
+};
+`)(step9ModuleSource);
+
+ await api.executeStep9({
+ oauthUrl: 'https://auth.openai.com/original-oauth',
+ visibleStep: 9,
+ });
+
+ const snapshot = api.snapshot();
+ assert.equal(snapshot.recoveryCalls, 0);
+ assert.equal(snapshot.remainingCalls >= 1, true);
+ assert.equal(snapshot.cleanupCalls >= 1, true);
+ assert.equal(snapshot.hasPendingReject, false);
+ assert.deepEqual(snapshot.completePayload, {
+ step: 9,
+ payload: {
+ localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
+ },
+ });
+});
diff --git a/tests/step9-timeout-recovery.test.js b/tests/step9-timeout-recovery.test.js
index eb4606d..e623472 100644
--- a/tests/step9-timeout-recovery.test.js
+++ b/tests/step9-timeout-recovery.test.js
@@ -11,6 +11,7 @@ let step8TabUpdatedListener = null;
let step8PendingReject = null;
let cleanupCalls = 0;
let timeoutCalls = 0;
+let remainingCalls = 0;
let recoveryCalls = 0;
let completePayload = null;
const logs = [];
@@ -25,7 +26,7 @@ const chrome = {
if (typeof webNavListener === 'function') {
webNavListener({ tabId: 123, url: callbackUrl });
}
- }, 0);
+ }, 25);
},
removeListener() {},
},
@@ -107,6 +108,11 @@ async function recoverOAuthLocalhostTimeout(details = {}) {
};
}
+async function getOAuthFlowRemainingMs() {
+ remainingCalls += 1;
+ return null;
+}
+
function getStep8CallbackUrlFromNavigation(details, signupTabId) {
if (
Number(signupTabId) === Number(details?.tabId)
@@ -163,6 +169,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
clickWithDebugger,
completeStepFromBackground,
ensureStep8SignupPageReady,
+ getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs,
getStep8CallbackUrlFromNavigation,
getStep8CallbackUrlFromTabUpdate,
@@ -197,6 +204,7 @@ return {
return {
cleanupCalls,
timeoutCalls,
+ remainingCalls,
recoveryCalls,
completePayload,
hasPendingReject: Boolean(step8PendingReject),