diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index e85b250..294e458 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -105,11 +105,16 @@ } } + const shouldRequestFreshCodeFirst = ![ + HOTMAIL_PROVIDER, + CLOUDFLARE_TEMP_EMAIL_PROVIDER, + ].includes(mail.provider); + await resolveVerificationStep(4, state, mail, { filterAfterTimestamp: verificationFilterAfterTimestamp, sessionKey: verificationSessionKey, disableTimeBudgetCap: mail.provider === '2925', - requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, + requestFreshCodeFirst: shouldRequestFreshCodeFirst, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, diff --git a/content/signup-page.js b/content/signup-page.js index a6d44d8..9b7548d 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1622,15 +1622,27 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) { async function waitForVerificationSubmitOutcome(step, timeout) { const resolvedTimeout = timeout ?? (step === 8 ? 30000 : 12000); const start = Date.now(); + let recoveryCount = 0; + const maxRecoveryCount = 2; while (Date.now() - start < resolvedTimeout) { throwIfStopped(); - if (step === 4) { - const signupRetryState = getCurrentAuthRetryPageState('signup'); - if (signupRetryState?.userAlreadyExistsBlocked) { - throw createSignupUserAlreadyExistsError(); - } + const retryFlow = step === 4 ? 'signup' : 'login'; + const retryState = getCurrentAuthRetryPageState(retryFlow); + if (retryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + if (retryState && recoveryCount < maxRecoveryCount) { + recoveryCount += 1; + log(`步骤 ${step}:验证码提交后进入认证重试页,正在自动恢复(${recoveryCount}/${maxRecoveryCount})...`, 'warn'); + await recoverCurrentAuthRetryPage({ + flow: retryFlow, + logLabel: `步骤 ${step}:验证码提交后检测到认证重试页,正在点击“重试”恢复`, + step, + timeoutMs: 12000, + }); + continue; } const errorText = getVerificationErrorText(); @@ -1670,6 +1682,76 @@ async function waitForVerificationSubmitOutcome(step, timeout) { return { success: true, assumed: true }; } +function getVerificationSubmitButtonForTarget(codeInput, options = {}) { + const { allowDisabled = false } = options; + const form = codeInput?.form || codeInput?.closest?.('form') || null; + const isUsableAction = (element) => { + if (!element || !isVisibleElement(element)) return false; + return allowDisabled || isActionEnabled(element); + }; + + const findSubmitInRoot = (root) => { + if (!root?.querySelectorAll) return null; + + const directCandidates = root.querySelectorAll('button[type="submit"], input[type="submit"]'); + for (const element of directCandidates) { + if (isUsableAction(element)) { + return element; + } + } + + const textCandidates = root.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]'); + return Array.from(textCandidates).find((element) => { + if (!isUsableAction(element)) return false; + const text = getActionText(element); + return /verify|confirm|submit|continue|确认|验证|继续/i.test(text); + }) || null; + }; + + return findSubmitInRoot(form) || findSubmitInRoot(document); +} + +async function waitForVerificationSubmitButton(codeInput, timeout = 5000) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + if (is405MethodNotAllowedPage()) { + throw new Error('当前页面处于 405 错误恢复流程中,暂时无法定位验证码提交按钮。'); + } + + const button = getVerificationSubmitButtonForTarget(codeInput, { allowDisabled: false }); + if (button) { + return button; + } + + await sleep(150); + } + + return null; +} + +async function waitForSplitVerificationInputsFilled(inputs, code, timeout = 2500) { + const expected = String(code || '').slice(0, 6); + const start = Date.now(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + const current = Array.from(inputs || []) + .slice(0, expected.length) + .map((input) => String(input?.value || '').trim()) + .join(''); + + if (current === expected) { + return true; + } + + await sleep(100); + } + + return false; +} + async function fillVerificationCode(step, payload) { const { code } = payload; if (!code) throw new Error('未提供验证码。'); @@ -1704,9 +1786,37 @@ async function fillVerificationCode(step, payload) { if (singleInputs.length >= 6) { log(`步骤 ${step}:发现分开的单字符验证码输入框,正在逐个填写...`); for (let i = 0; i < 6 && i < singleInputs.length; i++) { + const targetInput = singleInputs[i]; + try { + targetInput.focus?.(); + } catch {} fillInput(singleInputs[i], code[i]); + try { + targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: code[i], bubbles: true })); + } catch {} await sleep(100); } + const filled = await waitForSplitVerificationInputsFilled(singleInputs, code, 2500); + if (!filled) { + const current = Array.from(singleInputs) + .slice(0, 6) + .map((input) => String(input?.value || '').trim() || '_') + .join(''); + log(`步骤 ${step}:分格验证码输入框未稳定呈现目标值,当前页面值为 ${current},准备继续观察提交流程。`, 'warn'); + } else { + log(`步骤 ${step}:分格验证码输入框已稳定显示 ${code}。`, 'info'); + } + + await sleep(800); + const splitSubmitBtn = await waitForVerificationSubmitButton(singleInputs[0], 2000).catch(() => null); + if (splitSubmitBtn) { + await humanPause(450, 1200); + simulateClick(splitSubmitBtn); + log(`步骤 ${step}:分格验证码已提交`); + } else { + log(`步骤 ${step}:分格验证码页面未找到可点击提交按钮,继续等待页面自动推进。`, 'info'); + } + const outcome = await waitForVerificationSubmitOutcome(step); if (outcome.invalidCode) { log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn'); @@ -1736,17 +1846,16 @@ async function fillVerificationCode(step, payload) { fillInput(codeInput, code); log(`步骤 ${step}:验证码已填写`); - // Report complete BEFORE submit (page may navigate away) - // Submit - await sleep(500); - const submitBtn = document.querySelector('button[type="submit"]') - || await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null); + await sleep(800); + const submitBtn = await waitForVerificationSubmitButton(codeInput, 5000).catch(() => null); if (submitBtn) { await humanPause(450, 1200); simulateClick(submitBtn); log(`步骤 ${step}:验证码已提交`); + } else { + log(`步骤 ${step}:未找到可提交的验证码按钮,先等待页面自动推进或反馈结果。`, 'warn'); } const outcome = await waitForVerificationSubmitOutcome(step); diff --git a/tests/background-step4-filter-window.test.js b/tests/background-step4-filter-window.test.js index cced4bc..f9c77a9 100644 --- a/tests/background-step4-filter-window.test.js +++ b/tests/background-step4-filter-window.test.js @@ -55,3 +55,53 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling', assert.equal(capturedOptions.filterAfterTimestamp, 100000); assert.equal(capturedOptions.resendIntervalMs, 0); }); + +test('step 4 does not request a fresh code first for Cloudflare temp mail', async () => { + let capturedOptions = null; + const realDateNow = Date.now; + Date.now = () => 700000; + + const executor = api.createStep4Executor({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + completeStepFromBackground: async () => {}, + confirmCustomVerificationStepBypass: async () => {}, + ensureMail2925MailboxSession: async () => {}, + getMailConfig: () => ({ + provider: 'cloudflare-temp-email', + label: 'Cloudflare Temp Email', + source: 'cloudflare-temp-email', + url: 'https://temp.peekcart.com', + }), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => true, + LUCKMAIL_PROVIDER: 'luckmail-api', + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + resolveVerificationStep: async (_step, _state, _mail, options) => { + capturedOptions = options; + }, + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async () => ({}), + shouldUseCustomRegistrationEmail: () => false, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + throwIfStopped: () => {}, + }); + + try { + await executor.executeStep4({ + email: 'user@example.com', + password: 'secret', + }); + } finally { + Date.now = realDateNow; + } + + assert.equal(capturedOptions.filterAfterTimestamp, 700000); + assert.equal(capturedOptions.requestFreshCodeFirst, false); + assert.equal(capturedOptions.resendIntervalMs, 25000); +}); diff --git a/tests/step4-split-code-submit.test.js b/tests/step4-split-code-submit.test.js new file mode 100644 index 0000000..6f6010a --- /dev/null +++ b/tests/step4-split-code-submit.test.js @@ -0,0 +1,141 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/signup-page.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('fillVerificationCode submits after split inputs are stably filled', async () => { + const api = new Function(` +const logs = []; +const clicks = []; +const filledValues = []; +let submitClicked = false; + +const submitBtn = { + tagName: 'BUTTON', + textContent: 'Continue', + disabled: false, + getAttribute(name) { + if (name === 'type') return 'submit'; + if (name === 'aria-disabled') return 'false'; + return ''; + }, + click() { + submitClicked = true; + }, +}; + +const inputs = Array.from({ length: 6 }, () => ({ + value: '', + focus() {}, + dispatchEvent() {}, +})); + +const document = { + querySelector(selector) { + if (selector === 'button[type="submit"], input[type="submit"]') return submitBtn; + return null; + }, + querySelectorAll(selector) { + if (selector === 'input[maxlength="1"]') return inputs; + if (selector === 'button[type="submit"], input[type="submit"]') return [submitBtn]; + if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [submitBtn]; + return []; + }, +}; + +function throwIfStopped() {} +function log(message, level = 'info') { logs.push({ message, level }); } +async function waitForLoginVerificationPageReady() {} +function is405MethodNotAllowedPage() { return false; } +async function handle405ResendError() {} +async function waitForElement() { throw new Error('not found'); } +function fillInput(el, value) { + el.value = value; + filledValues.push(value); +} +async function sleep() {} +function isVisibleElement() { return true; } +function isActionEnabled(el) { return Boolean(el) && !el.disabled; } +function getActionText(el) { return el.textContent || ''; } +async function humanPause() {} +function simulateClick(el) { el.click(); clicks.push(el.textContent); } +async function waitForVerificationSubmitOutcome() { return { success: true }; } + +${extractFunction('getVerificationSubmitButtonForTarget')} +${extractFunction('waitForVerificationSubmitButton')} +${extractFunction('waitForSplitVerificationInputsFilled')} +${extractFunction('fillVerificationCode')} + +return { + run() { + return fillVerificationCode(4, { code: '123456' }); + }, + snapshot() { + return { + logs, + clicks, + filledValues, + submitClicked, + currentValue: inputs.map((input) => input.value).join(''), + }; + }, +}; +`)(); + + const result = await api.run(); + const snapshot = api.snapshot(); + + assert.deepStrictEqual(result, { success: true }); + assert.equal(snapshot.currentValue, '123456'); + assert.equal(snapshot.submitClicked, true); + assert.deepStrictEqual(snapshot.clicks, ['Continue']); +}); diff --git a/tests/step4-submit-retry-recovery.test.js b/tests/step4-submit-retry-recovery.test.js new file mode 100644 index 0000000..a9a7ed0 --- /dev/null +++ b/tests/step4-submit-retry-recovery.test.js @@ -0,0 +1,103 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/signup-page.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('waitForVerificationSubmitOutcome recovers signup retry page after submit', async () => { + const api = new Function(` +let retryVisible = true; +let step5Ready = false; +let recoverCalls = 0; +const location = { href: 'https://auth.openai.com/email-verification' }; + +function throwIfStopped() {} +function log() {} +function getVerificationErrorText() { return ''; } +function isStep5Ready() { return step5Ready; } +function isStep8Ready() { return false; } +function isAddPhonePageReady() { return false; } +function isVerificationPageStillVisible() { return false; } +function createSignupUserAlreadyExistsError() { + return new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。'); +} +function getCurrentAuthRetryPageState(flow) { + if (flow === 'signup' && retryVisible) { + return { + retryEnabled: true, + userAlreadyExistsBlocked: false, + }; + } + return null; +} +async function recoverCurrentAuthRetryPage() { + recoverCalls += 1; + retryVisible = false; + step5Ready = true; +} +async function sleep() {} + +${extractFunction('waitForVerificationSubmitOutcome')} + +return { + run() { + return waitForVerificationSubmitOutcome(4, 1000); + }, + snapshot() { + return { recoverCalls }; + }, +}; +`)(); + + const result = await api.run(); + + assert.deepStrictEqual(result, { success: true }); + assert.equal(api.snapshot().recoverCalls, 1); +});