diff --git a/background.js b/background.js index 99806ad..e789fc8 100644 --- a/background.js +++ b/background.js @@ -10913,9 +10913,11 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({ getLoginAuthStateLabel, getOAuthFlowStepTimeoutMs, getState, + getTabId, isAddPhoneAuthFailure, isStep6RecoverableResult, isStep6SuccessResult, + phoneVerificationHelpers, refreshOAuthUrlBeforeStep6, reuseOrCreateTab, sendToContentScriptResilient, diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 7c8e0bd..c8f0b02 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -18,6 +18,8 @@ }, isStep6RecoverableResult, isStep6SuccessResult, + getTabId, + phoneVerificationHelpers = null, refreshOAuthUrlBeforeStep6, reuseOrCreateTab, sendToContentScriptResilient, @@ -74,6 +76,49 @@ return normalizeStep7IdentifierType(fallbackType) || 'email'; } + function extractAddPhoneUrl(error) { + const message = String(typeof error === 'string' ? error : error?.message || ''); + const match = message.match(/https:\/\/auth\.openai\.com\/add-phone(?:[^\s]*)?/i); + return match ? match[0] : 'https://auth.openai.com/add-phone'; + } + + async function completeStep7AddPhoneHandoff(state = {}, err, completionStep) { + if (!state?.phoneVerificationEnabled) { + throw new Error( + `步骤 ${completionStep}:登录提交后页面进入手机号页面,必须先启用接码/phone verification 后才能继续。URL: ${extractAddPhoneUrl(err)}` + ); + } + if (typeof phoneVerificationHelpers?.completePhoneVerificationFlow !== 'function') { + throw new Error(`步骤 ${completionStep}:手机号验证流程不可用,接码模块尚未初始化。`); + } + if (typeof getTabId !== 'function') { + throw new Error(`步骤 ${completionStep}:无法定位认证页面标签页,不能继续手机号验证。`); + } + + const signupTabId = await getTabId('signup-page'); + if (!Number.isInteger(signupTabId)) { + throw new Error(`步骤 ${completionStep}:认证页面标签页已关闭,无法继续手机号验证。`); + } + + const pageState = { + addPhonePage: true, + phoneVerificationPage: false, + state: 'add_phone_page', + url: extractAddPhoneUrl(err), + }; + await phoneVerificationHelpers.completePhoneVerificationFlow(signupTabId, pageState, { + step: completionStep, + visibleStep: completionStep, + }); + await completeStepFromBackground(completionStep, { + loginVerificationRequestedAt: null, + skipLoginVerificationStep: true, + directOAuthConsentPage: true, + phoneVerification: true, + loginPhoneVerification: true, + }); + } + async function executeStep7(state) { const visibleStep = Math.floor(Number(state?.visibleStep) || 0); const completionStep = visibleStep > 0 ? visibleStep : 7; @@ -218,7 +263,15 @@ } catch (err) { throwIfStopped(err); if (isAddPhoneAuthFailure(err)) { - throw err; + const latestAddPhoneState = typeof getState === 'function' + ? await getState().catch(() => state) + : state; + await completeStep7AddPhoneHandoff( + { ...(state || {}), ...(latestAddPhoneState || {}) }, + err, + completionStep + ); + return; } if (isManagementSecretConfigError(err)) { await addLog( diff --git a/background/verification-flow.js b/background/verification-flow.js index d3fd5e5..ffb5158 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -1,8 +1,8 @@ (function attachBackgroundVerificationFlow(root, factory) { root.MultiPageBackgroundVerificationFlow = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundVerificationFlowModule() { - const ICLOUD_MAIL_POLL_RESPONSE_TIMEOUT_CAP_MS = 18000; - const ICLOUD_MAIL_POLL_TOTAL_TIMEOUT_CAP_MS = 22000; + const ICLOUD_MAIL_POLL_MIN_ATTEMPTS = 5; + const ICLOUD_MAIL_POLL_TIMEOUT_MARGIN_MS = 25000; function createVerificationFlowHelpers(deps = {}) { const { @@ -78,30 +78,54 @@ return step === 4 ? '注册' : '登录'; } - function capIcloudMailPollingTimeouts(mail, timedPoll) { + function isIcloudMail(mail) { + return mail?.source === 'icloud-mail' || mail?.provider === 'icloud'; + } + + function normalizeIcloudMailPollPayload(mail, payload = {}) { + if (!isIcloudMail(mail)) { + return payload; + } + + const currentAttempts = Math.max(1, Math.floor(Number(payload?.maxAttempts) || 1)); + if (currentAttempts >= ICLOUD_MAIL_POLL_MIN_ATTEMPTS) { + return payload; + } + + return { + ...payload, + maxAttempts: ICLOUD_MAIL_POLL_MIN_ATTEMPTS, + }; + } + + function getMailPollingResponseTimeoutMs(payload = {}) { + const maxAttempts = Math.max(1, Math.floor(Number(payload?.maxAttempts) || 1)); + const intervalMs = Math.max(1, Number(payload?.intervalMs) || 3000); + return Math.max(45000, maxAttempts * intervalMs + ICLOUD_MAIL_POLL_TIMEOUT_MARGIN_MS); + } + + function resolveMailPollingTimeouts(mail, timedPoll) { + const payload = normalizeIcloudMailPollPayload(mail, timedPoll?.payload || {}); const defaultResponseTimeoutMs = Math.max(1000, Number(timedPoll?.responseTimeoutMs) || 30000); const defaultTimeoutMs = Math.max(defaultResponseTimeoutMs, Number(timedPoll?.timeoutMs) || defaultResponseTimeoutMs); - if (mail?.source !== 'icloud-mail') { + if (!isIcloudMail(mail)) { return { + payload, responseTimeoutMs: defaultResponseTimeoutMs, timeoutMs: defaultTimeoutMs, - capped: false, }; } - const cappedResponseTimeoutMs = Math.max( - 5000, - Math.min(defaultResponseTimeoutMs, ICLOUD_MAIL_POLL_RESPONSE_TIMEOUT_CAP_MS) - ); - const cappedTimeoutMs = Math.max( - cappedResponseTimeoutMs, - Math.min(defaultTimeoutMs, ICLOUD_MAIL_POLL_TOTAL_TIMEOUT_CAP_MS) + const derivedResponseTimeoutMs = Math.max( + defaultResponseTimeoutMs, + getMailPollingResponseTimeoutMs(payload) ); + const derivedTimeoutMs = Math.max(defaultTimeoutMs, derivedResponseTimeoutMs); return { - responseTimeoutMs: cappedResponseTimeoutMs, - timeoutMs: cappedTimeoutMs, - capped: cappedResponseTimeoutMs < defaultResponseTimeoutMs || cappedTimeoutMs < defaultTimeoutMs, + payload, + responseTimeoutMs: derivedResponseTimeoutMs, + timeoutMs: derivedTimeoutMs, }; } @@ -707,20 +731,14 @@ pollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱` ); - const timeoutWindow = capIcloudMailPollingTimeouts(mail, timedPoll); - if (timeoutWindow.capped) { - await addLog( - `步骤 ${step}:iCloud 邮箱轮询已启用快速超时保护(${Math.ceil(timeoutWindow.timeoutMs / 1000)} 秒),避免页面无响应导致长时间卡住。`, - 'info' - ); - } + const timeoutWindow = resolveMailPollingTimeouts(mail, timedPoll); const result = await sendToMailContentScriptResilient( mail, { type: 'POLL_EMAIL', step, source: 'background', - payload: timedPoll.payload, + payload: timeoutWindow.payload, }, { timeoutMs: timeoutWindow.timeoutMs, @@ -982,20 +1000,14 @@ pollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱` ); - const timeoutWindow = capIcloudMailPollingTimeouts(mail, timedPoll); - if (timeoutWindow.capped) { - await addLog( - `步骤 ${step}:iCloud 邮箱轮询已启用快速超时保护(${Math.ceil(timeoutWindow.timeoutMs / 1000)} 秒),避免页面无响应导致长时间卡住。`, - 'info' - ); - } + const timeoutWindow = resolveMailPollingTimeouts(mail, timedPoll); const result = await sendToMailContentScriptResilient( mail, { type: 'POLL_EMAIL', step, source: 'background', - payload: timedPoll.payload, + payload: timeoutWindow.payload, }, { timeoutMs: timeoutWindow.timeoutMs, diff --git a/docs/superpowers/drift-adjudication/2026-05-08-icloud-polling-addphone-fix-plan.md b/docs/superpowers/drift-adjudication/2026-05-08-icloud-polling-addphone-fix-plan.md new file mode 100644 index 0000000..341af2c --- /dev/null +++ b/docs/superpowers/drift-adjudication/2026-05-08-icloud-polling-addphone-fix-plan.md @@ -0,0 +1,12 @@ +# iCloud Polling And Add Phone Handoff Drift Adjudication Ledger + +Immutable spec path: `/root/projects/resister-codex/codex-oauth-automation-extension-upstream-base-vu8.0/codex-oauth-automation-extension/.worktrees/fix-icloud-polling-addphone-handoff/docs/superpowers/specs/2026-05-08-icloud-polling-addphone-fix-design.md` + +Immutable plan path: `/root/projects/resister-codex/codex-oauth-automation-extension-upstream-base-vu8.0/codex-oauth-automation-extension/.worktrees/fix-icloud-polling-addphone-handoff/docs/superpowers/plans/2026-05-08-icloud-polling-addphone-fix-plan.md` + +Controller-only mutation rule: only the P9/controller may create, append, supersede, close, summarize, or otherwise mutate this ledger. Subagents may read it, cite `DRIFT_ID`, report evidence, and request supersession, but may not edit it directly. + +## Active Adjudications Summary + +| DRIFT_ID | STATUS | SCOPE | P9_FINAL_ADJUDICATION | EFFECTIVE_EXECUTION_CONTRACT | +|---|---|---|---|---| diff --git a/docs/superpowers/plans/2026-05-08-icloud-polling-addphone-fix-plan.md b/docs/superpowers/plans/2026-05-08-icloud-polling-addphone-fix-plan.md new file mode 100644 index 0000000..8725da4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-icloud-polling-addphone-fix-plan.md @@ -0,0 +1,85 @@ +# iCloud Polling And Add Phone Handoff Fix Plan + +## Goal + +Implement the fix described in `docs/superpowers/specs/2026-05-08-icloud-polling-addphone-fix-design.md` on the Ultra8.0 operation-delay branch. + +## Architecture + +- Keep operation-delay excluded from polling. +- Adjust iCloud polling in the verification flow and/or iCloud polling payload construction so normal no-code polling uses a larger iCloud-specific attempt/time window. +- Reuse the existing shared phone verification flow when Step 7 lands on `/add-phone` and phone verification is enabled. +- Preserve fatal behavior when phone verification is disabled. + +## Implementation Granularity + +- Implementer granularity: TaskGroup. +- Reviewer granularity: TaskGroup. +- One checkpoint commit after both spec and code reviewers approve. + +## TaskGroup 1: iCloud polling and Step 7 add-phone handoff + +### Task 1: Expand iCloud polling window without using operation-delay + +Files likely involved: + +- `background/verification-flow.js` +- `content/icloud-mail.js` +- `tests/verification-flow-polling.test.js` +- `tests/icloud-mail-content.test.js` +- Existing operation-delay exclusion tests as regression coverage. + +Required behavior: + +- Add failing tests proving iCloud Step 4 no-code polling is not configured as one attempt under normal conditions. +- Add failing tests proving iCloud response timeout is large enough to cover the configured attempt count and interval. +- Implement the minimum production change that gives iCloud at least 5 configured polling attempts and a response timeout derived from the iCloud polling window. +- Do not wrap iCloud `POLL_EMAIL` handlers with `performOperationWithDelay`. +- Keep provider-specific fast-failure for actual transport non-response. + +Suggested verification: + +```bash +node --test tests/verification-flow-polling.test.js tests/icloud-mail-content.test.js tests/mail-polling-operation-delay-exclusion.test.js +``` + +### Task 2: Continue Step 7 direct add-phone through shared phone verification when enabled + +Files likely involved: + +- `content/signup-page.js` +- `background/steps/oauth-login.js` +- `background.js` +- `tests/background-step6-retry-limit.test.js` +- `tests/auto-run-step6-restart.test.js` +- `tests/step7-phone-login-entry.test.js` +- `tests/step8-retry-page-recovery.test.js` +- `tests/phone-verification-flow.test.js` + +Required behavior: + +- Add failing tests for Step 7 password submit landing on `/add-phone` with phone verification enabled. +- Add or preserve tests for Step 7 password submit landing on `/add-phone` with phone verification disabled. +- Add or preserve tests proving phone-login entry pages are not misclassified as add-phone. +- Reuse existing shared phone verification helpers; do not implement new phone/SMS submission logic in Step 7. +- Keep existing fatal propagation for shared phone verification failures. + +Suggested verification: + +```bash +node --test tests/background-step6-retry-limit.test.js tests/auto-run-step6-restart.test.js tests/step7-phone-login-entry.test.js tests/step8-retry-page-recovery.test.js tests/phone-verification-flow.test.js +``` + +## Final Verification + +Run these before completion: + +```bash +node --test tests/verification-flow-polling.test.js tests/icloud-mail-content.test.js tests/mail-polling-operation-delay-exclusion.test.js tests/operation-delay-injection.test.js tests/content-operation-delay.test.js +node --test tests/background-step6-retry-limit.test.js tests/auto-run-step6-restart.test.js tests/step7-phone-login-entry.test.js tests/step8-retry-page-recovery.test.js tests/phone-verification-flow.test.js +npm test +git diff --check +git status --short --branch --untracked-files=all +``` + +If `npm test` creates `scripts/__pycache__/`, remove it before final status. diff --git a/docs/superpowers/specs/2026-05-08-icloud-polling-addphone-fix-design.md b/docs/superpowers/specs/2026-05-08-icloud-polling-addphone-fix-design.md new file mode 100644 index 0000000..3db92bc --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-icloud-polling-addphone-fix-design.md @@ -0,0 +1,60 @@ +# iCloud Polling And Add Phone Handoff Fix Spec + +## Goal + +Fix two Ultra8.0 runtime failures on top of the migrated operation-delay branch: + +- iCloud verification email polling must wait longer and perform more than one check before declaring the code missing. +- Step 7 password login that lands directly on `https://auth.openai.com/add-phone` must continue into the existing phone verification flow when phone verification is enabled, instead of failing immediately. + +## Non-Goals + +- Do not add operation-delay waits to email polling, SMS polling, backend retries, or background timers. +- Do not change non-iCloud provider polling cadence unless required by shared test harness setup. +- Do not duplicate phone verification logic inside Step 7. +- Do not treat ordinary phone-login entry pages as add-phone pages. +- Do not continue add-phone when phone verification is disabled or when the existing phone verification flow reports a fatal error. + +## iCloud Polling Behavior + +The current failure log shows Step 4 iCloud polling using fast timeout protection with a 22 second response timeout and a `最多 1 次` polling payload, then failing after roughly 3 seconds. This is too short for the user's active iCloud-only workflow. + +For iCloud verification-code polling: + +- Step 4 signup-code polling and Step 8 login-code polling must not be reduced to a single attempt under normal no-code conditions. +- The iCloud polling payload must use an iCloud-specific minimum attempt floor of at least 5 attempts. +- The iCloud polling interval must allow delayed mail arrival and may be longer than the current 3 second effective no-code window. +- The tab-message response timeout for iCloud polling must be large enough for the configured iCloud polling attempts and intervals to complete, plus a small transport margin. +- Transport-unresponsive protection must remain: if the iCloud content script itself does not respond, the flow should fail or recover through the existing transport-error path instead of hanging indefinitely. +- Logs must accurately describe the actual iCloud attempt count and wait window. +- Operation-delay remains excluded from all email polling paths. + +## Add Phone Handoff Behavior + +When Step 7 password submission transitions directly to an add-phone page: + +- If phone verification is enabled, Step 7 must not throw the current fatal `提交密码后页面直接进入手机号页面,未经过登录验证码页` error. +- The flow must hand off to the existing shared phone verification implementation used by later steps. +- After successful phone verification, the automation should continue through the existing OAuth/login flow instead of stopping the run. +- If phone verification is disabled, the flow must keep a clear fatal error telling the user that add-phone requires phone verification to be enabled. +- Existing fatal conditions from the shared phone verification flow remain fatal, including unavailable phone numbers, provider failures, phone already exists, and user stop. +- Existing phone-login entry detection must remain separate from add-phone detection. + +## Acceptance Criteria + +- Reproducing the reported iCloud Step 4 path no longer emits `开始轮询 iCloud 邮箱(最多 1 次)` for normal iCloud no-code polling. +- iCloud Step 4 no-code polling performs at least 5 configured attempts before the missing-code failure path. +- iCloud polling response timeout is derived from the configured iCloud attempt count and interval rather than being capped to a too-short single-attempt window. +- Mail polling operation-delay exclusion tests still pass. +- Existing non-iCloud provider tests continue to pass. +- Step 7 password-submit to `/add-phone` with phone verification enabled continues into the shared phone verification flow. +- Step 7 password-submit to `/add-phone` with phone verification disabled still fails clearly. +- Step 7 phone-login entry pages are still treated as login phone input, not add-phone. +- Relevant targeted tests and full `npm test` pass. + +## Verification Targets + +- `node --test tests/verification-flow-polling.test.js tests/icloud-mail-content.test.js` +- `node --test tests/background-step6-retry-limit.test.js tests/auto-run-step6-restart.test.js tests/step7-phone-login-entry.test.js tests/step8-retry-page-recovery.test.js tests/phone-verification-flow.test.js` +- `node --test tests/mail-polling-operation-delay-exclusion.test.js tests/operation-delay-injection.test.js tests/content-operation-delay.test.js` +- `npm test` diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index 579977b..dbc949c 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -188,6 +188,174 @@ test('step 7 exits internal retry loop immediately when add-phone is detected', ); }); +test('step 7 hands direct add-phone to shared phone verification when enabled', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + refreshCalls: 0, + phoneCalls: [], + completions: [], + }; + + const executor = api.createStep7Executor({ + addLog: async () => {}, + completeStepFromBackground: async (step, payload) => { + events.completions.push({ step, payload }); + }, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ + email: 'user@example.com', + password: 'secret', + phoneVerificationEnabled: true, + }), + getTabId: async (sourceName) => (sourceName === 'signup-page' ? 91 : 0), + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + phoneVerificationHelpers: { + completePhoneVerificationFlow: async (tabId, pageState, options) => { + events.phoneCalls.push({ tabId, pageState, options }); + return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize/resume' }; + }, + }, + refreshOAuthUrlBeforeStep6: async () => { + events.refreshCalls += 1; + return `https://oauth.example/${events.refreshCalls}`; + }, + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async () => { + throw new Error('提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: https://auth.openai.com/add-phone'); + }, + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await executor.executeStep7({ + email: 'user@example.com', + password: 'secret', + phoneVerificationEnabled: true, + }); + + assert.equal(events.refreshCalls, 1); + assert.deepStrictEqual(events.phoneCalls, [ + { + tabId: 91, + pageState: { + addPhonePage: true, + phoneVerificationPage: false, + state: 'add_phone_page', + url: 'https://auth.openai.com/add-phone', + }, + options: { + step: 7, + visibleStep: 7, + }, + }, + ]); + assert.deepStrictEqual(events.completions, [ + { + step: 7, + payload: { + loginVerificationRequestedAt: null, + skipLoginVerificationStep: true, + directOAuthConsentPage: true, + phoneVerification: true, + loginPhoneVerification: true, + }, + }, + ]); +}); + +test('step 7 direct add-phone stays fatal when phone verification is disabled', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + phoneCalls: 0, + completions: 0, + }; + + const executor = api.createStep7Executor({ + addLog: async () => {}, + completeStepFromBackground: async () => { + events.completions += 1; + }, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ email: 'user@example.com', password: 'secret', phoneVerificationEnabled: false }), + getTabId: async () => 91, + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + phoneVerificationHelpers: { + completePhoneVerificationFlow: async () => { + events.phoneCalls += 1; + return {}; + }, + }, + refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest', + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async () => { + throw new Error('提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: https://auth.openai.com/add-phone'); + }, + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => executor.executeStep7({ email: 'user@example.com', password: 'secret', phoneVerificationEnabled: false }), + /手机号页面.*接码|phone verification/i + ); + assert.equal(events.phoneCalls, 0); + assert.equal(events.completions, 0); +}); + +test('step 7 propagates fatal errors from shared add-phone verification', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + phoneCalls: 0, + completions: 0, + }; + + const executor = api.createStep7Executor({ + addLog: async () => {}, + completeStepFromBackground: async () => { + events.completions += 1; + }, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ email: 'user@example.com', password: 'secret', phoneVerificationEnabled: true }), + getTabId: async () => 91, + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + phoneVerificationHelpers: { + completePhoneVerificationFlow: async () => { + events.phoneCalls += 1; + throw new Error('步骤 9:没有可用手机号。'); + }, + }, + refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest', + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async () => { + throw new Error('提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: https://auth.openai.com/add-phone'); + }, + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => executor.executeStep7({ email: 'user@example.com', password: 'secret', phoneVerificationEnabled: true }), + /没有可用手机号/ + ); + assert.equal(events.phoneCalls, 1); + assert.equal(events.completions, 0); +}); + test('step 7 starts a new oauth timeout window for each refreshed oauth url', async () => { const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); const globalScope = {}; diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 85a5ea8..db19f89 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -6,6 +6,43 @@ const source = fs.readFileSync('background/verification-flow.js', 'utf8'); const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope); +function createVerificationFlowTestHelpers(overrides = {}) { + return api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + remove: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + CLOUD_MAIL_PROVIDER: 'cloudmail', + completeStepFromBackground: async () => {}, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async () => ({}), + pollCloudMailVerificationCode: async () => ({}), + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async () => ({}), + sendToMailContentScriptResilient: async () => ({}), + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + ...overrides, + }); +} + test('verification flow keeps 2925 polling cadence in the default payload', () => { const helpers = api.createVerificationFlowHelpers({ addLog: async () => {}, @@ -43,6 +80,68 @@ test('verification flow keeps 2925 polling cadence in the default payload', () = assert.equal(step8Payload.intervalMs, 15000); }); +test('verification flow keeps iCloud step 4 polling at least five attempts under a short remaining budget', async () => { + const pollRequests = []; + const helpers = createVerificationFlowTestHelpers({ + sendToMailContentScriptResilient: async (_mail, message, options = {}) => { + pollRequests.push({ payload: message.payload, options }); + return {}; + }, + }); + + await assert.rejects( + helpers.pollFreshVerificationCode( + 4, + { email: 'user@example.com', mailProvider: 'icloud', lastSignupCode: null }, + { source: 'icloud-mail', provider: 'icloud', label: 'iCloud 邮箱' }, + { + filterAfterTimestamp: 123456, + getRemainingTimeMs: async () => 3000, + maxResendRequests: 0, + } + ), + /邮箱轮询结束|无法获取/ + ); + + assert.equal(pollRequests.length, 1); + const [{ payload, options }] = pollRequests; + assert.equal(payload.maxAttempts >= 5, true); + assert.equal(payload.intervalMs >= 3000, true); + assert.equal(options.responseTimeoutMs >= (payload.maxAttempts * payload.intervalMs) + 10000, true); +}); + +test('verification flow keeps iCloud step 8 polling at least five attempts before no-code failure', async () => { + const pollRequests = []; + const helpers = createVerificationFlowTestHelpers({ + sendToMailContentScriptResilient: async (_mail, message, options = {}) => { + pollRequests.push({ payload: message.payload, options }); + return {}; + }, + }); + + await assert.rejects( + helpers.pollFreshVerificationCodeWithResendInterval( + 8, + { email: 'user@example.com', mailProvider: 'icloud', lastLoginCode: null }, + { source: 'icloud-mail', provider: 'icloud', label: 'iCloud 邮箱' }, + { + filterAfterTimestamp: 123456, + getRemainingTimeMs: async () => 3000, + maxResendRequests: 0, + resendIntervalMs: 25000, + } + ), + /空轮询循环|停止当前链路/ + ); + + assert.equal(pollRequests.length >= 1, true); + assert.equal(pollRequests.every(({ payload }) => payload.maxAttempts >= 5), true); + assert.equal( + pollRequests.every(({ payload, options }) => options.responseTimeoutMs >= (payload.maxAttempts * payload.intervalMs) + 10000), + true + ); +}); + test('verification flow only enables 2925 target email matching in receive mode', () => { const helpers = api.createVerificationFlowHelpers({ addLog: async () => {}, @@ -1599,8 +1698,8 @@ test('verification flow stops iCloud poll-only loop after repeated no-code round assert.equal(resendRequests, 0); }); -test('verification flow caps iCloud polling response timeout to avoid long silent stalls', async () => { - const pollTimeouts = []; +test('verification flow derives iCloud polling response timeout from the configured polling window', async () => { + const pollRequests = []; const helpers = api.createVerificationFlowHelpers({ addLog: async () => {}, @@ -1630,8 +1729,9 @@ test('verification flow caps iCloud polling response timeout to avoid long silen } return {}; }, - sendToMailContentScriptResilient: async (_mail, _message, options = {}) => { - pollTimeouts.push({ + sendToMailContentScriptResilient: async (_mail, message, options = {}) => { + pollRequests.push({ + payload: message.payload, timeoutMs: Number(options.timeoutMs) || 0, responseTimeoutMs: Number(options.responseTimeoutMs) || 0, }); @@ -1659,10 +1759,13 @@ test('verification flow caps iCloud polling response timeout to avoid long silen /空轮询循环|停止当前链路/ ); - assert.equal(pollTimeouts.length > 0, true); - assert.equal(pollTimeouts.every(({ timeoutMs }) => timeoutMs > 0 && timeoutMs <= 22000), true); + assert.equal(pollRequests.length > 0, true); assert.equal( - pollTimeouts.every(({ responseTimeoutMs }) => responseTimeoutMs > 0 && responseTimeoutMs <= 18000), + pollRequests.every(({ payload, responseTimeoutMs }) => responseTimeoutMs >= (payload.maxAttempts * payload.intervalMs) + 10000), + true + ); + assert.equal( + pollRequests.every(({ timeoutMs, responseTimeoutMs }) => timeoutMs >= responseTimeoutMs), true ); });