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),