diff --git a/background.js b/background.js index 02ef4c82..bd03c818 100644 --- a/background.js +++ b/background.js @@ -12353,6 +12353,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedNodeId, + getNodeIdsForState, getPendingAutoRunTimerPlan, getRunningNodeIds, getState, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 3fb405c8..02067911 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -19,6 +19,7 @@ getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedNodeId, + getNodeIdsForState, getPendingAutoRunTimerPlan, getRunningNodeIds, getState, @@ -65,6 +66,78 @@ return false; } + const EMPTY_REGISTRATION_EMAIL_STATE = Object.freeze({ + current: '', + previous: '', + source: '', + updatedAt: 0, + }); + const DONE_NODE_STATUSES = new Set(['completed', 'manual_completed', 'skipped']); + + function isPhoneSignupFlow(state = {}) { + const signupMethod = String(state?.signupMethod || state?.resolvedSignupMethod || '').trim().toLowerCase(); + return signupMethod === 'phone'; + } + + function getFullWorkflowNodeIds(state = {}) { + if (typeof getNodeIdsForState === 'function') { + const nodeIds = getNodeIdsForState(state); + if (Array.isArray(nodeIds) && nodeIds.length) { + return Array.from(new Set( + nodeIds + .map((nodeId) => String(nodeId || '').trim()) + .filter(Boolean) + )); + } + } + return getKnownNodeIdsFromState(state); + } + + function isFullWorkflowDone(state = {}) { + const nodeIds = getFullWorkflowNodeIds(state); + if (!nodeIds.length) { + return false; + } + const nodeStatuses = state?.nodeStatuses || {}; + return nodeIds.every((nodeId) => ( + DONE_NODE_STATUSES.has(String(nodeStatuses[nodeId] || 'pending').trim()) + )); + } + + async function clearPhoneSignupRuntimeAfterRoundSuccess() { + const currentState = await getState(); + if ( + !isPhoneSignupFlow(currentState) + || !isFullWorkflowDone(currentState) + ) { + return false; + } + + await setState({ + accountIdentifierType: null, + accountIdentifier: '', + currentPhoneActivation: null, + phoneNumber: '', + signupPhoneNumber: '', + signupPhoneActivation: null, + signupPhoneCompletedActivation: null, + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + currentPhoneVerificationCode: '', + currentPhoneVerificationCountdownEndsAt: 0, + currentPhoneVerificationCountdownWindowIndex: 0, + currentPhoneVerificationCountdownWindowTotal: 0, + email: null, + registrationEmailState: { ...EMPTY_REGISTRATION_EMAIL_STATE }, + step8VerificationTargetEmail: '', + lastEmailTimestamp: null, + lastSignupCode: '', + lastLoginCode: '', + bindEmailSubmitted: false, + }); + return true; + } + async function waitForRunningWorkflowNodesToFinish(payload = {}) { if (typeof waitForRunningNodesToFinish === 'function') { return waitForRunningNodesToFinish(payload); @@ -698,6 +771,7 @@ attemptRuns: attemptRun, continued: useExistingProgress, }); + await clearPhoneSignupRuntimeAfterRoundSuccess(); roundSummary.status = 'success'; roundSummary.finalFailureReason = ''; diff --git a/flows/openai/background/steps/create-plus-checkout.js b/flows/openai/background/steps/create-plus-checkout.js index a912624a..135693da 100644 --- a/flows/openai/background/steps/create-plus-checkout.js +++ b/flows/openai/background/steps/create-plus-checkout.js @@ -10,6 +10,11 @@ const PLUS_PAYMENT_METHOD_PAYPAL_HOSTED = 'paypal-hosted'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; + const LOCAL_CHECKOUT_PROXY_HEALTH_URL = 'http://127.0.0.1:21988/health'; + const LOCAL_CHECKOUT_PROXY_URL = 'socks5://127.0.0.1:21987'; + const LOCAL_CHECKOUT_PROXY_SETTINGS_SCOPE = 'regular'; + const LOCAL_CHECKOUT_PROXY_TIMEOUT_MS = 1200; + const LOCAL_CHECKOUT_PROXY_SETTLE_MS = 350; const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; @@ -24,6 +29,7 @@ const PAYPAL_HOSTED_STAGE_LOGIN = 'pay_login'; const PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT = 'guest_checkout'; const PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT = 'create_account'; + const PAYPAL_HOSTED_STAGE_SECURITY_CODE = 'security_code'; const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent'; const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval'; const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown'; @@ -58,6 +64,7 @@ sleepWithStop, waitForTabCompleteUntilStopped, waitForTabUrlMatchUntilStopped = null, + withCheckoutCreationProxy = null, throwIfStopped = () => {}, } = deps; @@ -78,6 +85,194 @@ }); } + function parseSocks5Endpoint(proxyUrl = '') { + const text = String(proxyUrl || '').trim(); + if (!text) { + return null; + } + let parsed = null; + try { + parsed = new URL(text); + } catch { + return null; + } + if (String(parsed.protocol || '').replace(/:$/, '').toLowerCase() !== 'socks5') { + return null; + } + const host = String(parsed.hostname || '').replace(/^\[|\]$/g, '').trim(); + const port = Number.parseInt(String(parsed.port || ''), 10); + if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) { + return null; + } + return { host, port }; + } + + function buildCheckoutCreationPacScript(endpoint) { + const proxyHost = String(endpoint?.host || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const port = Number.parseInt(String(endpoint?.port || ''), 10); + return ` +function FindProxyForURL(url, host) { + host = String(host || '').toLowerCase(); + if (host === 'chatgpt.com' || dnsDomainIs(host, '.chatgpt.com')) { + return "SOCKS5 ${proxyHost}:${port}"; + } + return "DIRECT"; +}`.trim(); + } + + function callChromeProxySettings(method, details = {}) { + const proxySettings = chrome?.proxy?.settings; + if (!proxySettings || typeof proxySettings[method] !== 'function') { + return Promise.reject(new Error('当前浏览器不支持扩展代理 API')); + } + return new Promise((resolve, reject) => { + proxySettings[method](details, (value) => { + const lastError = chrome?.runtime?.lastError; + if (lastError) { + reject(new Error(lastError.message || String(lastError))); + return; + } + resolve(value); + }); + }); + } + + function canControlProxySettings(details = {}) { + const level = String(details?.levelOfControl || '').trim(); + return !level || level === 'controlled_by_this_extension' || level === 'controllable_by_this_extension'; + } + + async function readProxySettingsSnapshot() { + return callChromeProxySettings('get', { incognito: false }); + } + + async function restoreProxySettingsSnapshot(snapshot = null) { + const value = snapshot?.value; + const level = String(snapshot?.levelOfControl || '').trim(); + if (level === 'controlled_by_this_extension' && value && typeof value === 'object') { + await callChromeProxySettings('set', { + value, + scope: LOCAL_CHECKOUT_PROXY_SETTINGS_SCOPE, + }); + return; + } + await callChromeProxySettings('clear', { + scope: LOCAL_CHECKOUT_PROXY_SETTINGS_SCOPE, + }); + } + + async function fetchLocalCheckoutProxyHealth() { + if (typeof fetchImpl !== 'function') { + return null; + } + const controller = typeof AbortController === 'function' ? new AbortController() : null; + let timer = null; + try { + timer = controller + ? setTimeout(() => controller.abort(), LOCAL_CHECKOUT_PROXY_TIMEOUT_MS) + : null; + const response = await fetchImpl(`${LOCAL_CHECKOUT_PROXY_HEALTH_URL}?t=${Date.now()}`, { + method: 'GET', + cache: 'no-store', + headers: { Accept: 'application/json,text/plain,*/*' }, + ...(controller ? { signal: controller.signal } : {}), + }); + if (!response?.ok) { + return null; + } + const payload = await response.json().catch(() => ({})); + if (!payload?.ok) { + return null; + } + const endpoint = parseSocks5Endpoint(payload.localProxy || LOCAL_CHECKOUT_PROXY_URL); + return endpoint ? { endpoint, payload } : null; + } catch { + return null; + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + async function applyTemporaryCheckoutProxy(endpoint) { + const pacScript = buildCheckoutCreationPacScript(endpoint); + await callChromeProxySettings('set', { + value: { + mode: 'pac_script', + pacScript: { + data: pacScript, + mandatory: true, + }, + }, + scope: LOCAL_CHECKOUT_PROXY_SETTINGS_SCOPE, + }); + } + + async function runWithLocalCheckoutCreationProxy(action) { + if (typeof withCheckoutCreationProxy === 'function') { + return withCheckoutCreationProxy({ + healthUrl: LOCAL_CHECKOUT_PROXY_HEALTH_URL, + localProxyUrl: LOCAL_CHECKOUT_PROXY_URL, + }, action); + } + if (!chrome?.proxy?.settings || typeof fetchImpl !== 'function') { + return action(); + } + + const health = await fetchLocalCheckoutProxyHealth(); + if (!health?.endpoint) { + return action(); + } + + let snapshot = null; + let applied = false; + let result = null; + let proxyError = null; + let restoreFailed = false; + try { + try { + snapshot = await readProxySettingsSnapshot(); + } catch (error) { + return action(); + } + if (!canControlProxySettings(snapshot)) { + return action(); + } + await applyTemporaryCheckoutProxy(health.endpoint); + applied = true; + await sleepWithStop(LOCAL_CHECKOUT_PROXY_SETTLE_MS); + result = await action(); + if (result?.error && !result?.stopped) { + proxyError = new Error(result.error); + } + } catch (error) { + if (!applied) { + return action(); + } + proxyError = error; + } finally { + if (applied) { + try { + await restoreProxySettingsSnapshot(snapshot); + await sleepWithStop(LOCAL_CHECKOUT_PROXY_SETTLE_MS); + } catch (error) { + restoreFailed = true; + } + } + } + if (result && !proxyError) { + return result; + } + if (proxyError) { + if (restoreFailed) { + throw proxyError; + } + return action(); + } + return null; + } + function normalizePlusPaymentMethod(value = '') { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) { @@ -589,6 +784,16 @@ return result || {}; } + async function submitHostedPayPalSecurityCode(tabId, config = {}, stepKey = PAYPAL_HOSTED_STEP_CREATE_ACCOUNT) { + const stepNumber = getHostedStepNumber(stepKey); + const verificationCode = await pollHostedVerificationCode(config.verificationUrl); + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:已获取 PayPal 手机验证码,正在填写。`, 'info'); + return runHostedPayPalStep(tabId, { + expectedStage: PAYPAL_HOSTED_STAGE_SECURITY_CODE, + securityCode: verificationCode, + }); + } + function getHostedStageOrder(stage = '') { switch (stage) { case PAYPAL_HOSTED_STAGE_LOGIN: @@ -597,6 +802,8 @@ return 2; case PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT: return 3; + case PAYPAL_HOSTED_STAGE_SECURITY_CODE: + return 3.5; case PAYPAL_HOSTED_STAGE_REVIEW: return 4; case PAYPAL_HOSTED_STAGE_OUTSIDE: @@ -634,7 +841,7 @@ try { const pageState = await getHostedPayPalState(tabId); lastStage = pageState?.hostedStage || lastStage; - if (predicate(pageState)) { + if (await predicate(pageState)) { return pageState; } } catch (error) { @@ -934,6 +1141,19 @@ } const pageState = await getHostedPayPalState(tabId); + const config = await getHostedCheckoutRuntimeConfig(state); + if (pageState.hostedStage === PAYPAL_HOSTED_STAGE_SECURITY_CODE) { + await submitHostedPayPalSecurityCode(tabId, config, stepKey); + const nextState = await waitForHostedPayPalStage( + tabId, + (stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_SECURITY_CODE, + { label: `步骤 ${stepNumber}:等待 PayPal 验证码提交后跳转` } + ); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: nextState.hostedStage || '', + }); + return; + } if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_REVIEW) && pageState.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) { await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),创建确认节点直接完成。`, 'info'); @@ -952,7 +1172,13 @@ }); const nextState = await waitForHostedPayPalStage( tabId, - (stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT, + async (stateInfo) => { + if (stateInfo?.hostedStage === PAYPAL_HOSTED_STAGE_SECURITY_CODE) { + await submitHostedPayPalSecurityCode(tabId, config, stepKey); + return false; + } + return stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT; + }, { label: `步骤 ${stepNumber}:等待 PayPal 创建确认页跳转` } ); await completeHostedStep(stepKey, tabId, { @@ -1392,7 +1618,9 @@ const paymentMethodLabel = getPlusPaymentMethodLabel(paymentMethod); const checkoutModeLabel = getCheckoutModeLabel(state); await addLog(`步骤 6:正在打开新的 ChatGPT 会话,准备创建${checkoutModeLabel}...`, 'info'); - const tabId = await openFreshChatGptTabForCheckoutCreate(); + let tabId = 0; + const createCheckout = async () => { + tabId = await openFreshChatGptTabForCheckoutCreate(); await waitForTabCompleteUntilStopped(tabId); await sleepWithStop(1000); @@ -1402,11 +1630,15 @@ logMessage: '步骤 6:正在等待 ChatGPT 页面完成加载,再继续创建订阅页...', }); - const result = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, { + return sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, { type: 'CREATE_PLUS_CHECKOUT', source: 'background', payload: { paymentMethod }, }); + }; + const result = paymentMethod === PLUS_PAYMENT_METHOD_PAYPAL_HOSTED + ? await runWithLocalCheckoutCreationProxy(createCheckout) + : await createCheckout(); if (result?.error) { throw new Error(result.error); diff --git a/flows/openai/content/paypal-flow.js b/flows/openai/content/paypal-flow.js index 8f899439..613a5f75 100644 --- a/flows/openai/content/paypal-flow.js +++ b/flows/openai/content/paypal-flow.js @@ -8,6 +8,7 @@ const PAYPAL_HOSTED_STAGE_OUTSIDE = 'outside_paypal'; const PAYPAL_HOSTED_STAGE_LOGIN = 'pay_login'; const PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT = 'guest_checkout'; const PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT = 'create_account'; +const PAYPAL_HOSTED_STAGE_SECURITY_CODE = 'security_code'; const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent'; const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval'; const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown'; @@ -15,6 +16,7 @@ const PAYPAL_HOSTED_STEP_KEYS = { [PAYPAL_HOSTED_STAGE_LOGIN]: 'paypal-hosted-email', [PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT]: 'paypal-hosted-card', [PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT]: 'paypal-hosted-create-account', + [PAYPAL_HOSTED_STAGE_SECURITY_CODE]: 'paypal-hosted-create-account', [PAYPAL_HOSTED_STAGE_REVIEW]: 'paypal-hosted-review', }; @@ -308,10 +310,63 @@ function findHostedReviewConsentButton() { ]); } +function getHostedSecurityCodeInputs() { + const pageText = normalizeText(document.body?.innerText || document.body?.textContent || ''); + const pageLooksLikeSecurityCode = /enter\s+(?:your\s+)?code|6[-\s]*digit\s+code|security\s+code|verification\s+code|we\s+sent\s+a\s+6[-\s]*digit\s+code/i.test(pageText); + const visibleInputs = getVisibleControls('input') + .filter((input) => { + const type = String(input.getAttribute?.('type') || input.type || '').trim().toLowerCase(); + return isEnabledControl(input) + && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type); + }); + const candidates = visibleInputs + .filter((input) => { + const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0); + const metadata = getActionText(input); + return maxLength === 1 || /otp|code|verification|security|one[-\s]*time/i.test(metadata); + }); + if (candidates.length < 6 && pageLooksLikeSecurityCode && visibleInputs.length >= 6) { + return visibleInputs.slice(0, 6); + } + const singleDigitInputs = candidates.filter((input) => { + const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0); + const valueLength = String(input.value || '').length; + return maxLength === 1 || valueLength <= 1; + }); + return singleDigitInputs.length >= 6 ? singleDigitInputs.slice(0, 6) : candidates; +} + +function getHostedSecurityCodeSingleInput() { + return getVisibleControls('input').find((input) => { + const type = String(input.getAttribute?.('type') || input.type || '').trim().toLowerCase(); + const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0); + const metadata = getActionText(input); + return isEnabledControl(input) + && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type) + && maxLength >= 6 + && /otp|code|verification|security|one[-\s]*time/i.test(metadata); + }) || null; +} + +function findHostedSecurityCodeSubmitButton() { + return findClickableByText([ + /submit|continue|next|verify|confirm|done/i, + ]); +} + +function isHostedSecurityCodePage() { + const pageText = normalizeText(document.body?.innerText || document.body?.textContent || ''); + const hasSecurityText = /enter\s+(?:your\s+)?code|6[-\s]*digit\s+code|security\s+code|verification\s+code|we\s+sent\s+a\s+6[-\s]*digit\s+code/i.test(pageText); + return hasSecurityText && (getHostedSecurityCodeInputs().length >= 6 || Boolean(getHostedSecurityCodeSingleInput())); +} + function detectPayPalHostedStage() { if (!/paypal\./i.test(String(location?.host || ''))) { return PAYPAL_HOSTED_STAGE_OUTSIDE; } + if (isHostedSecurityCodePage()) { + return PAYPAL_HOSTED_STAGE_SECURITY_CODE; + } if (isHostedGuestCheckoutPage()) { return PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT; } @@ -465,6 +520,48 @@ function verifyHostedPhoneBeforeSubmit(expectedPhone = '') { }; } +function normalizeHostedSecurityCode(value = '') { + const code = String(value || '').replace(/\D/g, '').slice(0, 6); + return /^\d{6}$/.test(code) ? code : ''; +} + +async function submitHostedSecurityCode(payload = {}) { + await waitForDocumentComplete(); + const code = normalizeHostedSecurityCode(payload.securityCode || payload.verificationCode || payload.code || ''); + if (!code) { + throw new Error('PayPal hosted checkout 验证码为空或不是 6 位数字。'); + } + const digitInputs = getHostedSecurityCodeInputs(); + const singleInput = getHostedSecurityCodeSingleInput(); + if (digitInputs.length >= 6) { + digitInputs.slice(0, 6).forEach((input, index) => { + fillInput(input, code[index]); + }); + } else if (singleInput) { + fillInput(singleInput, code); + } else { + throw new Error('PayPal hosted checkout 未找到验证码输入框。'); + } + + const submitButton = findHostedSecurityCodeSubmitButton(); + if (submitButton && isVisibleElement(submitButton) && isEnabledControl(submitButton)) { + await performPayPalOperationWithDelay({ + stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_SECURITY_CODE), + kind: 'click', + label: 'hosted-paypal-security-code-submit', + }, async () => { + simulateClick(submitButton); + }); + } + await sleep(1000); + return { + stage: PAYPAL_HOSTED_STAGE_SECURITY_CODE, + securityCodeSubmitted: true, + submitted: Boolean(submitButton), + inputCount: digitInputs.length >= 6 ? 6 : 1, + }; +} + async function clickHostedCreateAccount(payload = {}) { await waitForDocumentComplete(); const button = await waitUntil(() => { @@ -640,6 +737,9 @@ async function runPayPalHostedCheckoutStep(payload = {}) { if (stage === PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) { return clickHostedCreateAccount(payload); } + if (stage === PAYPAL_HOSTED_STAGE_SECURITY_CODE) { + return submitHostedSecurityCode(payload); + } if (stage === PAYPAL_HOSTED_STAGE_REVIEW) { return clickHostedReviewConsent(); } @@ -659,6 +759,7 @@ function inspectPayPalHostedState() { hostedStage: stage, hasGuestCardFields: Boolean(document.getElementById('cardNumber')), hasHostedEmailInput: Boolean(document.getElementById('email') || findEmailInput()), + securityCodeVisible: stage === PAYPAL_HOSTED_STAGE_SECURITY_CODE, createAccountReady: Boolean(createAccountButton && isVisibleElement(createAccountButton) && isEnabledControl(createAccountButton)), reviewConsentReady: Boolean(findHostedReviewConsentButton()), approveReady: Boolean(findApproveButton()), diff --git a/tests/auto-run-phone-signup-success-email-cleanup.test.js b/tests/auto-run-phone-signup-success-email-cleanup.test.js new file mode 100644 index 00000000..f0bec595 --- /dev/null +++ b/tests/auto-run-phone-signup-success-email-cleanup.test.js @@ -0,0 +1,325 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadAutoRunControllerApi() { + const source = fs.readFileSync('background/auto-run-controller.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope); +} + +const FULL_NODE_IDS = [ + 'open-chatgpt', + 'submit-signup-email', + 'fill-password', + 'fetch-signup-code', + 'fill-profile', + 'wait-registration-success', + 'oauth-login', + 'fetch-login-code', + 'confirm-oauth', + 'platform-verify', +]; + +const EMPTY_REGISTRATION_EMAIL_STATE = { + current: '', + previous: '', + source: '', + updatedAt: 0, +}; + +const PHONE_NUMBER = '+6612345'; +const PHONE_ACTIVATION = { + activationId: 'signup-completed', + phoneNumber: PHONE_NUMBER, +}; + +function createNodeStatuses(completedNodeIds = []) { + const completedSet = new Set(completedNodeIds); + return Object.fromEntries( + FULL_NODE_IDS.map((nodeId) => [nodeId, completedSet.has(nodeId) ? 'completed' : 'pending']) + ); +} + +function createBaseState() { + return { + activeFlowId: 'openai', + flowId: 'openai', + signupMethod: 'phone', + resolvedSignupMethod: 'phone', + mailProvider: '163', + emailGenerator: 'cloudflare-temp-email', + autoRunFallbackThreadIntervalMinutes: 0, + autoRunSkipFailures: false, + stepExecutionRangeByFlow: { + openai: { + enabled: true, + fromStep: 1, + toStep: 3, + }, + }, + nodeStatuses: createNodeStatuses([]), + stepStatuses: {}, + }; +} + +function createHarness({ completedNodeIds = [] } = {}) { + const api = loadAutoRunControllerApi(); + let sessionSeed = 1000; + let currentState = createBaseState(); + let runCalls = 0; + + const clone = (value) => JSON.parse(JSON.stringify(value)); + + async function getState() { + return clone(currentState); + } + + async function setState(updates = {}) { + currentState = { + ...currentState, + ...updates, + nodeStatuses: updates.nodeStatuses ? { ...updates.nodeStatuses } : currentState.nodeStatuses, + stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + }; + } + + async function resetState() { + currentState = { + activeFlowId: currentState.activeFlowId, + flowId: currentState.flowId, + signupMethod: currentState.signupMethod, + resolvedSignupMethod: currentState.resolvedSignupMethod, + mailProvider: currentState.mailProvider, + emailGenerator: currentState.emailGenerator, + autoRunFallbackThreadIntervalMinutes: currentState.autoRunFallbackThreadIntervalMinutes, + autoRunSkipFailures: currentState.autoRunSkipFailures, + stepExecutionRangeByFlow: clone(currentState.stepExecutionRangeByFlow), + nodeStatuses: createNodeStatuses([]), + stepStatuses: {}, + currentPhoneActivation: currentState.currentPhoneActivation, + phoneNumber: currentState.phoneNumber, + accountIdentifierType: currentState.accountIdentifierType, + accountIdentifier: currentState.accountIdentifier, + signupPhoneNumber: currentState.signupPhoneNumber, + signupPhoneActivation: currentState.signupPhoneActivation, + signupPhoneCompletedActivation: currentState.signupPhoneCompletedActivation, + signupPhoneVerificationRequestedAt: currentState.signupPhoneVerificationRequestedAt, + signupPhoneVerificationPurpose: currentState.signupPhoneVerificationPurpose, + email: null, + registrationEmailState: { ...EMPTY_REGISTRATION_EMAIL_STATE }, + currentPhoneVerificationCode: currentState.currentPhoneVerificationCode, + currentPhoneVerificationCountdownEndsAt: currentState.currentPhoneVerificationCountdownEndsAt, + currentPhoneVerificationCountdownWindowIndex: currentState.currentPhoneVerificationCountdownWindowIndex, + currentPhoneVerificationCountdownWindowTotal: currentState.currentPhoneVerificationCountdownWindowTotal, + lastEmailTimestamp: currentState.lastEmailTimestamp, + lastSignupCode: currentState.lastSignupCode, + lastLoginCode: currentState.lastLoginCode, + bindEmailSubmitted: currentState.bindEmailSubmitted, + }; + } + + async function runAutoSequenceFromNode() { + runCalls += 1; + if (runCalls === 2) { + assert.equal(currentState.email, null); + assert.equal(currentState.currentPhoneActivation, null); + assert.equal(currentState.phoneNumber, ''); + assert.equal(currentState.signupPhoneNumber, ''); + assert.equal(currentState.accountIdentifierType, null); + assert.equal(currentState.accountIdentifier, ''); + assert.equal(currentState.signupPhoneActivation, null); + assert.equal(currentState.signupPhoneCompletedActivation, null); + } + await setState({ + accountIdentifierType: 'phone', + accountIdentifier: PHONE_NUMBER, + currentPhoneActivation: PHONE_ACTIVATION, + phoneNumber: PHONE_NUMBER, + signupPhoneNumber: PHONE_NUMBER, + signupPhoneActivation: PHONE_ACTIVATION, + signupPhoneCompletedActivation: PHONE_ACTIVATION, + currentPhoneVerificationCode: '222222', + currentPhoneVerificationCountdownEndsAt: Date.now() + 60000, + currentPhoneVerificationCountdownWindowIndex: 1, + currentPhoneVerificationCountdownWindowTotal: 2, + email: 'bound.user@example.com', + registrationEmailState: { + current: 'bound.user@example.com', + previous: 'old.bound@example.com', + source: 'bind_email', + updatedAt: 123, + }, + step8VerificationTargetEmail: 'bound.user@example.com', + lastEmailTimestamp: 456, + lastSignupCode: '111111', + lastLoginCode: '222222', + bindEmailSubmitted: true, + nodeStatuses: createNodeStatuses(completedNodeIds), + }); + } + + const runtime = { + state: { + autoRunActive: false, + autoRunCurrentRun: 0, + autoRunTotalRuns: 1, + autoRunAttemptRun: 0, + autoRunSessionId: 0, + }, + get() { + return { ...this.state }; + }, + set(updates = {}) { + this.state = { ...this.state, ...updates }; + }, + }; + + const controller = api.createAutoRunController({ + addLog: async () => {}, + AUTO_RUN_MAX_RETRIES_PER_ROUND: 3, + AUTO_RUN_RETRY_DELAY_MS: 3000, + AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry', + AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds', + broadcastAutoRunStatus: async (phase, payload = {}, extraState = {}) => { + await setState({ + ...extraState, + autoRunning: phase !== 'idle', + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun || 0, + autoRunTotalRuns: payload.totalRuns || 1, + autoRunAttemptRun: payload.attemptRun || 0, + autoRunSessionId: payload.sessionId || 0, + }); + }, + broadcastStopToContentScripts: async () => {}, + cancelPendingCommands: () => {}, + clearStopRequest: () => {}, + createAutoRunSessionId: () => { + sessionSeed += 1; + return sessionSeed; + }, + getAutoRunStatusPayload: (phase, payload = {}) => ({ + autoRunning: phase !== 'idle', + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun || 0, + autoRunTotalRuns: payload.totalRuns || 1, + autoRunAttemptRun: payload.attemptRun || 0, + autoRunSessionId: payload.sessionId || 0, + }), + getErrorMessage: (error) => error?.message || String(error || ''), + getFirstUnfinishedNodeId: () => 'open-chatgpt', + getNodeIdsForState: () => FULL_NODE_IDS.slice(), + getPendingAutoRunTimerPlan: () => null, + getRunningNodeIds: () => [], + getState, + getStopRequested: () => false, + hasSavedNodeProgress: () => false, + isRestartCurrentAttemptError: () => false, + isStopError: () => false, + launchAutoRunTimerPlan: async () => false, + normalizeAutoRunFallbackThreadIntervalMinutes: () => 0, + persistAutoRunTimerPlan: async () => {}, + resetState, + runAutoSequenceFromNode, + runtime, + setState, + sleepWithStop: async () => {}, + throwIfAutoRunSessionStopped: () => {}, + waitForRunningNodesToFinish: getState, + chrome: { + runtime: { + sendMessage: () => Promise.resolve(), + }, + }, + }); + + return { + controller, + currentStateRef: () => currentState, + }; +} + +test('auto-run clears bound email runtime only after the full workflow completes', async () => { + const { controller, currentStateRef } = createHarness({ + completedNodeIds: FULL_NODE_IDS, + }); + + await controller.autoRunLoop(1, { mode: 'restart', autoRunSkipFailures: false }); + + const currentState = currentStateRef(); + assert.equal(currentState.email, null); + assert.deepEqual(currentState.registrationEmailState, EMPTY_REGISTRATION_EMAIL_STATE); + assert.equal(currentState.step8VerificationTargetEmail, ''); + assert.equal(currentState.lastEmailTimestamp, null); + assert.equal(currentState.lastSignupCode, ''); + assert.equal(currentState.lastLoginCode, ''); + assert.equal(currentState.bindEmailSubmitted, false); + assert.equal(currentState.currentPhoneActivation, null); + assert.equal(currentState.currentPhoneVerificationCode, ''); + assert.equal(currentState.currentPhoneVerificationCountdownEndsAt, 0); + assert.equal(currentState.currentPhoneVerificationCountdownWindowIndex, 0); + assert.equal(currentState.currentPhoneVerificationCountdownWindowTotal, 0); + assert.equal(currentState.accountIdentifierType, null); + assert.equal(currentState.accountIdentifier, ''); + assert.equal(currentState.phoneNumber, ''); + assert.equal(currentState.signupPhoneNumber, ''); + assert.equal(currentState.signupPhoneActivation, null); + assert.equal(currentState.signupPhoneCompletedActivation, null); +}); + +test('auto-run keeps bound email runtime when only part of the workflow completed', async () => { + const { controller, currentStateRef } = createHarness({ + completedNodeIds: [ + 'open-chatgpt', + 'submit-signup-email', + 'fill-password', + ], + }); + + await controller.autoRunLoop(1, { mode: 'restart', autoRunSkipFailures: false }); + + const currentState = currentStateRef(); + assert.equal(currentState.email, 'bound.user@example.com'); + assert.deepEqual(currentState.registrationEmailState, { + current: 'bound.user@example.com', + previous: 'old.bound@example.com', + source: 'bind_email', + updatedAt: 123, + }); + assert.equal(currentState.step8VerificationTargetEmail, 'bound.user@example.com'); + assert.equal(currentState.lastEmailTimestamp, 456); + assert.equal(currentState.lastSignupCode, '111111'); + assert.equal(currentState.lastLoginCode, '222222'); + assert.equal(currentState.bindEmailSubmitted, true); + assert.deepEqual(currentState.currentPhoneActivation, PHONE_ACTIVATION); + assert.equal(currentState.currentPhoneVerificationCode, '222222'); + assert.equal(currentState.currentPhoneVerificationCountdownEndsAt > 0, true); + assert.equal(currentState.currentPhoneVerificationCountdownWindowIndex, 1); + assert.equal(currentState.currentPhoneVerificationCountdownWindowTotal, 2); + assert.equal(currentState.accountIdentifierType, 'phone'); + assert.equal(currentState.accountIdentifier, PHONE_NUMBER); + assert.equal(currentState.phoneNumber, PHONE_NUMBER); + assert.equal(currentState.signupPhoneNumber, PHONE_NUMBER); + assert.deepEqual(currentState.signupPhoneActivation, PHONE_ACTIVATION); + assert.deepEqual(currentState.signupPhoneCompletedActivation, PHONE_ACTIVATION); +}); + +test('auto-run clears phone and email runtime so the next run cannot reuse them', async () => { + const { controller, currentStateRef } = createHarness({ + completedNodeIds: FULL_NODE_IDS, + }); + + await controller.autoRunLoop(2, { mode: 'restart', autoRunSkipFailures: false }); + + const currentState = currentStateRef(); + assert.equal(currentState.email, null); + assert.equal(currentState.currentPhoneActivation, null); + assert.equal(currentState.phoneNumber, ''); + assert.equal(currentState.signupPhoneNumber, ''); + assert.equal(currentState.accountIdentifierType, null); + assert.equal(currentState.accountIdentifier, ''); + assert.equal(currentState.signupPhoneActivation, null); + assert.equal(currentState.signupPhoneCompletedActivation, null); + assert.equal(currentState.bindEmailSubmitted, false); +}); diff --git a/tests/paypal-flow-content.test.js b/tests/paypal-flow-content.test.js index db41f5c8..ea0ae8b8 100644 --- a/tests/paypal-flow-content.test.js +++ b/tests/paypal-flow-content.test.js @@ -413,6 +413,16 @@ function createHostedPayPalHarness(options = {}) { id: 'btnNext', text: '下一页', }); + const securityCodeInputs = Array.from({ length: 6 }, (_value, index) => createDomElement({ + tagName: 'INPUT', + id: `securityCode${index + 1}`, + type: 'text', + })); + const securityCodeContinueButton = createDomElement({ + tagName: 'BUTTON', + id: 'securityCodeContinue', + text: 'Continue', + }); function setElements(nextElements) { elements = nextElements; @@ -464,6 +474,15 @@ function createHostedPayPalHarness(options = {}) { setElements([emailInput, nextButton, createAccountButton]); } + function showSecurityCode() { + location.href = 'https://www.paypal.com/checkoutweb/security-code'; + location.host = 'www.paypal.com'; + location.pathname = '/checkoutweb/security-code'; + body.innerText = 'Enter your code We sent a 6-digit code to (835) 253-1607 Resend'; + body.textContent = body.innerText; + setElements([...securityCodeInputs, securityCodeContinueButton]); + } + const context = { console: { log() {}, warn() {}, error() {}, info() {} }, location, @@ -558,6 +577,7 @@ function createHostedPayPalHarness(options = {}) { showPayEmail, showCreateAccount, showGuestCheckout, + showSecurityCode, }; } @@ -687,3 +707,37 @@ test('PayPal hosted create account page is detected and handled as its own step' [{ stepKey: 'paypal-hosted-create-account', kind: 'click', label: 'hosted-paypal-create-account' }] ); }); + +test('PayPal hosted security code page fills six digit code inputs', async () => { + const harness = createHostedPayPalHarness(); + harness.showSecurityCode(); + + const state = await harness.send({ + type: 'PAYPAL_HOSTED_GET_STATE', + source: 'test', + payload: {}, + }); + assert.equal(state.ok, true); + assert.equal(state.hostedStage, 'security_code'); + assert.equal(state.securityCodeVisible, true); + + const result = await harness.send({ + type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP', + source: 'test', + payload: { + expectedStage: 'security_code', + securityCode: '921714', + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.stage, 'security_code'); + assert.equal(result.securityCodeSubmitted, true); + assert.deepEqual( + harness.events + .filter((event) => event.type === 'fill' && /^securityCode/.test(event.id)) + .map((event) => event.value), + ['9', '2', '1', '7', '1', '4'] + ); + assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'securityCodeContinue'), true); +}); diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js index 06ae2f26..f4d2eb6e 100644 --- a/tests/plus-checkout-create-wait.test.js +++ b/tests/plus-checkout-create-wait.test.js @@ -259,6 +259,7 @@ test('Plus checkout create does not wait 20 seconds after opening checkout page' test('GoPay plus checkout create forwards gopay payment method to the checkout content script', async () => { const events = []; + let proxyCallCount = 0; const executor = api.createPlusCheckoutCreateExecutor({ addLog: async () => {}, chrome: { @@ -281,11 +282,183 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c setState: async () => {}, sleepWithStop: async () => {}, waitForTabCompleteUntilStopped: async () => {}, + withCheckoutCreationProxy: async () => { + proxyCallCount += 1; + throw new Error('gopay checkout should not use the checkout proxy wrapper'); + }, }); await executor.executePlusCheckoutCreate({ plusPaymentMethod: 'gopay' }); assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' }); + assert.equal(proxyCallCount, 0); +}); + +test('PayPal no-card binding creates checkout inside the local checkout proxy wrapper', async () => { + const events = []; + const executor = api.createPlusCheckoutCreateExecutor({ + addLog: async () => {}, + chrome: { + tabs: { + create: async () => ({ id: 77, url: 'https://chatgpt.com/', status: 'complete' }), + update: async () => {}, + get: async () => ({ id: 77, url: 'https://www.paypal.com/pay?token=BA-wrapper', status: 'complete' }), + }, + }, + completeNodeFromBackground: async () => {}, + ensureContentScriptReadyOnTabUntilStopped: async () => {}, + fetch: async () => ({ + ok: true, + status: 200, + json: async () => ({ + address: { + Address: '1 Main St', + City: 'New York', + State: 'New York', + Zip_Code: '10001', + }, + }), + }), + getState: async () => ({ + hostedCheckoutPhoneNumber: '4155551234', + }), + registerTab: async () => {}, + sendTabMessageUntilStopped: async (_tabId, _source, message) => { + events.push({ type: 'tab-message', message, inProxy: events.some((event) => event.type === 'proxy-enter') && !events.some((event) => event.type === 'proxy-exit') }); + if (message.type === 'CREATE_PLUS_CHECKOUT') { + return { + checkoutUrl: 'https://chatgpt.com/checkout/openai_llc/cs_hosted', + preferredCheckoutUrl: 'https://pay.openai.com/c/pay/cs_hosted', + hostedCheckoutUrl: 'https://pay.openai.com/c/pay/cs_hosted', + country: 'US', + currency: 'USD', + }; + } + if (message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') { + return { clicked: true }; + } + throw new Error(`unexpected message type ${message.type}`); + }, + setState: async () => {}, + sleepWithStop: async () => {}, + waitForTabCompleteUntilStopped: async () => {}, + withCheckoutCreationProxy: async (config, action) => { + events.push({ type: 'proxy-enter', config }); + const result = await action(); + events.push({ type: 'proxy-exit' }); + return result; + }, + }); + + await executor.executePlusCheckoutCreate({ + plusPaymentMethod: 'paypal-hosted', + plusHostedCheckoutOauthDelaySeconds: 0, + }); + + assert.deepStrictEqual(events.find((event) => event.type === 'proxy-enter')?.config, { + healthUrl: 'http://127.0.0.1:21988/health', + localProxyUrl: 'socks5://127.0.0.1:21987', + }); + assert.equal( + events.find((event) => event.type === 'tab-message' && event.message.type === 'CREATE_PLUS_CHECKOUT')?.inProxy, + true + ); +}); + +test('PayPal no-card binding falls back to direct checkout when local helper proxy fails', async () => { + const events = []; + let createAttempts = 0; + const proxySettings = { + get(details, callback) { + events.push({ type: 'proxy-get', details }); + callback({ + levelOfControl: 'controllable_by_this_extension', + value: { mode: 'system' }, + }); + }, + set(details, callback) { + events.push({ type: 'proxy-set', details }); + callback(); + }, + clear(details, callback) { + events.push({ type: 'proxy-clear', details }); + callback(); + }, + }; + const executor = api.createPlusCheckoutCreateExecutor({ + addLog: async () => {}, + chrome: { + runtime: {}, + proxy: { + settings: proxySettings, + }, + tabs: { + create: async () => ({ id: 78, url: 'https://chatgpt.com/', status: 'complete' }), + update: async () => {}, + get: async () => ({ id: 78, url: 'https://www.paypal.com/pay?token=BA-direct', status: 'complete' }), + }, + }, + completeNodeFromBackground: async () => {}, + ensureContentScriptReadyOnTabUntilStopped: async () => {}, + fetch: async (url) => { + events.push({ type: 'fetch', url }); + if (String(url).startsWith('http://127.0.0.1:21988/health')) { + return { + ok: true, + status: 200, + json: async () => ({ ok: true, localProxy: 'socks5://127.0.0.1:21987' }), + }; + } + return { + ok: true, + status: 200, + json: async () => ({ + address: { + Address: '1 Main St', + City: 'New York', + State: 'New York', + Zip_Code: '10001', + }, + }), + }; + }, + getState: async () => ({ + hostedCheckoutPhoneNumber: '4155551234', + }), + registerTab: async () => {}, + sendTabMessageUntilStopped: async (_tabId, _source, message) => { + events.push({ type: 'tab-message', message }); + if (message.type === 'CREATE_PLUS_CHECKOUT') { + createAttempts += 1; + if (createAttempts === 1) { + return { error: 'proxy connect failed' }; + } + return { + checkoutUrl: 'https://chatgpt.com/checkout/openai_llc/cs_hosted', + preferredCheckoutUrl: 'https://www.paypal.com/pay?token=BA-direct', + hostedCheckoutUrl: 'https://www.paypal.com/pay?token=BA-direct', + country: 'US', + currency: 'USD', + }; + } + if (message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') { + return { clicked: true }; + } + throw new Error(`unexpected message type ${message.type}`); + }, + setState: async () => {}, + sleepWithStop: async () => {}, + waitForTabCompleteUntilStopped: async () => {}, + }); + + await executor.executePlusCheckoutCreate({ + plusPaymentMethod: 'paypal-hosted', + plusHostedCheckoutOauthDelaySeconds: 0, + }); + + assert.equal(createAttempts, 2); + assert.equal(events.some((event) => event.type === 'proxy-set' && event.details?.value?.mode === 'pac_script'), true); + assert.equal(events.some((event) => event.type === 'proxy-clear' && event.details?.scope === 'regular'), true); }); test('PayPal no-card binding create opens and submits hosted OpenAI checkout before completing', async () => { @@ -554,6 +727,89 @@ test('PayPal hosted email node completes when Next navigation drops the content ); }); +test('PayPal hosted create account node submits PayPal security code from SMS text payload', async () => { + const events = []; + let stage = 'create_account'; + const executor = api.createPlusCheckoutCreateExecutor({ + addLog: async (message, level = 'info', options = {}) => events.push({ type: 'log', message, level, options }), + chrome: { + tabs: { + get: async (tabId) => ({ id: tabId, url: 'https://www.paypal.com/checkoutweb/create-account', status: 'complete' }), + }, + }, + completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), + ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => events.push({ type: 'ready', source, tabId, options }), + fetch: async (url) => { + events.push({ type: 'fetch', url }); + if (url === 'https://www.meiguodizhi.com/api/v1/dz') { + return { + ok: true, + status: 200, + json: async () => ({ + address: { + Address: '1 Main St', + City: 'New York', + State: 'New York', + Zip_Code: '10001', + }, + }), + }; + } + assert.equal(String(url).startsWith('https://otp.example.test/latest?t='), true); + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + code: 1, + msg: 'ok', + data: { + code: "PayPal: 921714 is your security code. Don't share it.", + code_time: '2026-05-25 01:41:22', + expired_date: '2026-08-15 00:00:00', + }, + }), + }; + }, + getState: async () => ({ + hostedCheckoutVerificationUrl: 'https://otp.example.test/latest', + hostedCheckoutPhoneNumber: '8352531607', + }), + registerTab: async (source, tabId) => events.push({ type: 'register', source, tabId }), + sendTabMessageUntilStopped: async (tabId, source, message) => { + events.push({ type: 'tab-message', tabId, source, message }); + if (message.type === 'PAYPAL_HOSTED_GET_STATE') { + return { hostedStage: stage }; + } + if (message.type === 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP' && message.payload.expectedStage === 'create_account') { + stage = 'security_code'; + return { clicked: true, submitted: true, stage: 'create_account' }; + } + if (message.type === 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP' && message.payload.expectedStage === 'security_code') { + stage = 'review_consent'; + return { securityCodeSubmitted: true, stage: 'security_code' }; + } + throw new Error(`unexpected message type ${message.type}`); + }, + setState: async (payload) => events.push({ type: 'set-state', payload }), + sleepWithStop: async (ms) => events.push({ type: 'sleep', ms }), + waitForTabCompleteUntilStopped: async () => events.push({ type: 'tab-complete' }), + }); + + await executor.executePayPalHostedCreateAccount({ + plusCheckoutTabId: 55, + plusPaymentMethod: 'paypal-hosted', + }); + + assert.deepStrictEqual( + events.find((event) => event.type === 'tab-message' && event.message?.payload?.expectedStage === 'security_code')?.message?.payload, + { + expectedStage: 'security_code', + securityCode: '921714', + } + ); + assert.equal(events.some((event) => event.type === 'complete' && event.step === 'paypal-hosted-create-account'), true); +}); + test('Plus checkout content routes billing operations through the operation delay gate', async () => { const { checkoutEvents, send } = createCheckoutContentHarness(); diff --git "a/\351\241\271\347\233\256\345\256\214\346\225\264\351\223\276\350\267\257\350\257\264\346\230\216.md" "b/\351\241\271\347\233\256\345\256\214\346\225\264\351\223\276\350\267\257\350\257\264\346\230\216.md" index 80078ddb..b2ecdf36 100644 --- "a/\351\241\271\347\233\256\345\256\214\346\225\264\351\223\276\350\267\257\350\257\264\346\230\216.md" +++ "b/\351\241\271\347\233\256\345\256\214\346\225\264\351\223\276\350\267\257\350\257\264\346\230\216.md" @@ -1178,10 +1178,11 @@ Hide My Email 获取与管理链路: - 下一轮继续 7. 当前轮最终失败时,写入账号记录,并在自动重试链路中累计重试次数 - 手动停止与自动停止会写入“停止”状态(若后续同邮箱成功/失败,会被覆盖为最新状态) -8. 如果配置了线程间隔,则挂计时计划;计时计划会带上当前 `autoRunSessionId` -9. 旧 timer / 旧 alarm / 旧恢复入口只有在 session 仍有效时才允许恢复执行;Stop 会立即使当前 session 失效,防止“停止后旧倒计时又把流程重新拉起” -10. 如果启用 IP 代理,后台会在每轮成功后按 `ipProxyPoolTargetCount` 检查是否需要切换出口;候选代理不足时只记录日志并跳过切换 -11. 所有轮次结束后输出汇总,并清空当前 session 标识 +8. 当前轮最终成功时,如果本轮是手机号注册且当前 flow 的全部 workflow node 都已经完成,后台会清空 `signupPhone* / accountIdentifier* / phoneNumber / currentPhoneActivation` 与 `email / registrationEmailState / step8VerificationTargetEmail` 等运行态,避免下一轮继续复用上一轮手机号或绑定邮箱 +9. 如果配置了线程间隔,则挂计时计划;计时计划会带上当前 `autoRunSessionId` +10. 旧 timer / 旧 alarm / 旧恢复入口只有在 session 仍有效时才允许恢复执行;Stop 会立即使当前 session 失效,防止“停止后旧倒计时又把流程重新拉起” +11. 如果启用 IP 代理,后台会在每轮成功后按 `ipProxyPoolTargetCount` 检查是否需要切换出口;候选代理不足时只记录日志并跳过切换 +12. 所有轮次结束后输出汇总,并清空当前 session 标识 ## 9. 新增功能时最容易漏掉的地方 @@ -1251,4 +1252,3 @@ Hide My Email 获取与管理链路: - `2925` 在 Step 4 / Step 8 现在会携带固定的步骤开始时间窗口,实际筛选下限为“步骤开始时间向前回看 10 分钟”。 - 为了保留这段固定时间窗内已经到达的验证码邮件,后台不再在轮询开始前预先清空 2925 邮箱。 - `2925` 验证码最终提交成功后,后台仍会异步发送 `DELETE_ALL_EMAILS` 做收尾清理。 - diff --git "a/\351\241\271\347\233\256\346\226\207\344\273\266\347\273\223\346\236\204\350\257\264\346\230\216.md" "b/\351\241\271\347\233\256\346\226\207\344\273\266\347\273\223\346\236\204\350\257\264\346\230\216.md" index 3e90ee6d..322ffa90 100644 --- "a/\351\241\271\347\233\256\346\226\207\344\273\266\347\273\223\346\236\204\350\257\264\346\230\216.md" +++ "b/\351\241\271\347\233\256\346\226\207\344\273\266\347\273\223\346\236\204\350\257\264\346\230\216.md" @@ -48,7 +48,7 @@ ## `background/` - `background/account-run-history.js`:账号记录模块,负责以统一账号标识维护最新记录;邮箱注册以邮箱为主身份,手机号注册以手机号为主身份,同一轮后续绑定的手机号或邮箱会并入同一条记录并覆盖旧占位状态;模块统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,兼容 phone-only 与 email+phone 组合记录、空密码,并按默认本地 helper 地址自动尝试把完整快照同步到本地 helper;若 helper 未启动,则静默跳过。 -- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 Plus 模式与 PayPal 配置、`gmailBaseEmail`、`mail2925BaseEmail`、当前 2925 账号选择与 `stepExecutionRangeByFlow`,避免自动流程重置后丢失关键持久配置。 +- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 Plus 模式与 PayPal 配置、`gmailBaseEmail`、`mail2925BaseEmail`、当前 2925 账号选择与 `stepExecutionRangeByFlow`,避免自动流程重置后丢失关键持久配置;手机号注册自动轮次只有在完整 workflow 全部完成后,才会清空手机号和绑定邮箱运行态,避免下一轮复用旧身份。 - `background/contribution-oauth.js`:贡献模式的公开 OAuth 流程模块,负责调用 `flowpilot.qlhazycoder.top` 的公开贡献接口、保存贡献会话运行态、在主 10 步流程里为步骤 7 提供贡献登录地址、在步骤 9/10 衔接 callback 捕获与兼容提交 `/oauth/api/submit-callback`,并把真实服务端状态映射回 sidepanel 运行态。 - `background/cpa-api.js`:CPA 管理接口直连模块,负责请求 CPA OAuth 地址、提交 localhost callback,以及把当前 ChatGPT 已登录会话转换为 CPA `auth-files` 导入格式并直接写入管理接口。 - `background/cloudmail-provider.js`:Cloud Mail / SkyMail 后台 provider 模块,负责 Token 获取、邮箱创建、邮件列表读取、验证码轮询和生成/转发收件目标邮箱选择;新生成的 Cloud Mail 地址会统一走共享注册邮箱状态持久化,既回写带来源标记的注册邮箱运行态,也能在 Step 8 `add-email` 的手机号身份链路中保留 `signupPhone* / accountIdentifier*`。 @@ -245,6 +245,7 @@ - `tests/auto-run-hotmail-preflight.test.js`:测试自动运行控制器在每轮 fresh attempt 前会先预检 Hotmail 邮箱可用性。 - `tests/auto-run-step4-restart.test.js`:测试步骤 4 失败后的同邮箱重开、`user_already_exists` 终止分支、注册链跳过与手机号状态清理。 - `tests/auto-run-step4-mail2925-thread-terminate.test.js`:测试步骤 4 命中 2925“结束当前尝试”错误时,不会再沿用当前邮箱回到步骤 1 重开,而是直接把错误抛给自动重试控制器。 +- `tests/auto-run-phone-signup-success-email-cleanup.test.js`:测试手机号注册自动流程只有在完整 workflow 全部完成后才清理手机号和绑定邮箱运行态,并确认下一轮不会复用上一轮手机号或邮箱。 - `tests/auto-run-timer-session-guard.test.js`:测试自动运行的旧计时计划在 Stop 使 session 失效后,不能再把已停止的自动流程重新拉起。 - `tests/auto-run-add-phone-stop.test.js`:测试自动运行控制器在开启自动重试时,遇到 `add-phone / 手机号页` 会直接结束当前轮并继续下一轮,而不是把整条自动流程暂停。 - `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 或泛化手机号页 fatal 错误时停止重开。 @@ -433,4 +434,3 @@ - `tests/verification-stop-propagation.test.js`:测试验证码流程在 Stop 场景下的错误传播与不中途降级。 - `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数、验证码提交流程中的 `beforeSubmit` 钩子执行顺序,以及步骤 8 提交验证码后进入手机号页时会把“继续手机号验证”状态交给步骤 9 处理。 -