diff --git a/background.js b/background.js index dc315cd..b5ceb13 100644 --- a/background.js +++ b/background.js @@ -10616,6 +10616,12 @@ const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([ 'fetch-signup-code', 'wait-registration-success', 'plus-checkout-create', + 'paypal-hosted-openai-checkout', + 'paypal-hosted-email', + 'paypal-hosted-verification', + 'paypal-hosted-card', + 'paypal-hosted-create-account', + 'paypal-hosted-review', 'plus-checkout-billing', 'paypal-approve', 'plus-checkout-return', @@ -11715,6 +11721,12 @@ const AUTO_RUN_NODE_DELAYS = Object.freeze({ 'fill-profile': 0, 'wait-registration-success': 3000, 'plus-checkout-create': 3000, + 'paypal-hosted-openai-checkout': 2000, + 'paypal-hosted-email': 2000, + 'paypal-hosted-verification': 2000, + 'paypal-hosted-card': 2000, + 'paypal-hosted-create-account': 2000, + 'paypal-hosted-review': 2000, 'plus-checkout-billing': 2000, 'gopay-subscription-confirm': 2000, 'paypal-approve': 2000, @@ -13443,8 +13455,11 @@ const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.c createAutomationTab, ensureContentScriptReadyOnTabUntilStopped, fetch: typeof fetch === 'function' ? fetch.bind(globalThis) : null, + getTabId, getState, + isTabAlive, markCurrentRegistrationAccountUsed, + queryTabsInAutomationWindow, registerTab, sendTabMessageUntilStopped, setState, @@ -13727,6 +13742,12 @@ const stepExecutorsByKey = { 'fill-profile': (state) => step5Executor.executeStep5(state), 'wait-registration-success': (state) => step6Executor.executeStep6(state), 'plus-checkout-create': (state) => plusCheckoutCreateExecutor.executePlusCheckoutCreate(state), + 'paypal-hosted-openai-checkout': (state) => plusCheckoutCreateExecutor.executePayPalHostedOpenAiCheckout(state), + 'paypal-hosted-email': (state) => plusCheckoutCreateExecutor.executePayPalHostedEmail(state), + 'paypal-hosted-verification': (state) => plusCheckoutCreateExecutor.executePayPalHostedVerification(state), + 'paypal-hosted-card': (state) => plusCheckoutCreateExecutor.executePayPalHostedCard(state), + 'paypal-hosted-create-account': (state) => plusCheckoutCreateExecutor.executePayPalHostedCreateAccount(state), + 'paypal-hosted-review': (state) => plusCheckoutCreateExecutor.executePayPalHostedReview(state), 'plus-checkout-billing': (state) => plusCheckoutBillingExecutor.executePlusCheckoutBilling(state), 'gopay-subscription-confirm': (state) => goPayManualConfirmExecutor.executeGoPayManualConfirm(state), 'paypal-approve': (state) => normalizePlusPaymentMethod(state?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GOPAY diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index e3b1f71..e7018f9 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -20,6 +20,28 @@ const HOSTED_CHECKOUT_VERIFICATION_POLL_ATTEMPTS = 12; const HOSTED_CHECKOUT_VERIFICATION_POLL_INTERVAL_MS = 5000; const HOSTED_CHECKOUT_DEFAULT_PHONE = '1234567890'; + 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_VERIFICATION = 'verification'; + const PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT = 'create_account'; + const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent'; + const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval'; + const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown'; + const PAYPAL_HOSTED_STEP_OPENAI_CHECKOUT = 'paypal-hosted-openai-checkout'; + const PAYPAL_HOSTED_STEP_EMAIL = 'paypal-hosted-email'; + const PAYPAL_HOSTED_STEP_VERIFICATION = 'paypal-hosted-verification'; + const PAYPAL_HOSTED_STEP_CARD = 'paypal-hosted-card'; + const PAYPAL_HOSTED_STEP_CREATE_ACCOUNT = 'paypal-hosted-create-account'; + const PAYPAL_HOSTED_STEP_REVIEW = 'paypal-hosted-review'; + const PAYPAL_HOSTED_STEP_META = Object.freeze({ + [PAYPAL_HOSTED_STEP_OPENAI_CHECKOUT]: { step: 7, label: '无卡直绑 OpenAI Checkout' }, + [PAYPAL_HOSTED_STEP_EMAIL]: { step: 8, label: '无卡直绑 PayPal 邮箱页' }, + [PAYPAL_HOSTED_STEP_VERIFICATION]: { step: 9, label: '无卡直绑 PayPal 验证码页' }, + [PAYPAL_HOSTED_STEP_CARD]: { step: 10, label: '无卡直绑 PayPal 资料页' }, + [PAYPAL_HOSTED_STEP_CREATE_ACCOUNT]: { step: 11, label: '无卡直绑 PayPal 创建确认页' }, + [PAYPAL_HOSTED_STEP_REVIEW]: { step: 12, label: '无卡直绑 PayPal 授权复核页' }, + }); function createPlusCheckoutCreateExecutor(deps = {}) { const { @@ -29,7 +51,10 @@ createAutomationTab = null, ensureContentScriptReadyOnTabUntilStopped, fetch: fetchImpl = null, + getTabId = null, getState = null, + isTabAlive = null, + queryTabsInAutomationWindow = null, registerTab, sendTabMessageUntilStopped, setState, @@ -47,6 +72,15 @@ }); } + function addHostedStepLog(stepKey, message, level = 'info', options = {}) { + const meta = PAYPAL_HOSTED_STEP_META[stepKey] || {}; + return rawAddLog(message, level, { + step: meta.step || 6, + stepKey, + ...(options && typeof options === 'object' ? options : {}), + }); + } + function normalizePlusPaymentMethod(value = '') { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) { @@ -110,6 +144,178 @@ return /^https:\/\/(?:pay\.openai\.com|checkout\.stripe\.com)\/c\/pay(?:\/|$)/i.test(String(url || '')); } + function isHostedCheckoutRuntimeUrl(url = '') { + return isHostedOpenAiCheckoutUrl(url) || isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url); + } + + function getHostedStepNumber(stepKey = '') { + return PAYPAL_HOSTED_STEP_META[stepKey]?.step || 6; + } + + function normalizeHostedPhoneForPayload(phone = '') { + const digits = String(phone || '').replace(/\D/g, ''); + if (!digits) { + return HOSTED_CHECKOUT_DEFAULT_PHONE; + } + if (digits.length > 10 && digits.startsWith('1')) { + return digits.slice(-10); + } + return digits.length > 10 ? digits.slice(-10) : digits; + } + + function getHostedProfileFromState(state = {}) { + const profile = state?.plusHostedCheckoutGuestProfile || state?.hostedCheckoutGuestProfile || null; + return profile && typeof profile === 'object' && !Array.isArray(profile) ? profile : null; + } + + async function getLatestHostedState(state = {}) { + const latestState = typeof getState === 'function' ? await getState().catch(() => ({})) : {}; + return { + ...(latestState && typeof latestState === 'object' ? latestState : {}), + ...(state && typeof state === 'object' ? state : {}), + }; + } + + async function ensureHostedGuestProfile(state = {}) { + const mergedState = await getLatestHostedState(state); + const existingProfile = getHostedProfileFromState(mergedState) || {}; + const config = await getHostedCheckoutRuntimeConfig(mergedState); + const address = existingProfile.address && typeof existingProfile.address === 'object' + ? existingProfile.address + : await fetchHostedCheckoutAddress(); + const generatedProfile = buildHostedGuestProfile(address, { + phone: normalizeHostedPhoneForPayload(config.phone), + }); + const nextProfile = { + ...generatedProfile, + ...existingProfile, + address, + phone: normalizeHostedPhoneForPayload(config.phone || existingProfile.phone), + }; + await setState({ + plusHostedCheckoutGuestProfile: nextProfile, + plusHostedCheckoutPhoneDigits: nextProfile.phone, + }); + return { + profile: nextProfile, + config, + }; + } + + async function getTabById(tabId) { + const normalizedTabId = Number(tabId) || 0; + if (!normalizedTabId || !chrome?.tabs?.get) { + return null; + } + return chrome.tabs.get(normalizedTabId).catch(() => null); + } + + async function registerHostedCheckoutTab(tabId, url = '') { + if (typeof registerTab !== 'function' || !Number.isInteger(Number(tabId))) { + return; + } + await registerTab(isPayPalUrl(url) ? PAYPAL_SOURCE : PLUS_CHECKOUT_SOURCE, Number(tabId)); + } + + async function findOpenHostedCheckoutTabId() { + const queryTabs = typeof queryTabsInAutomationWindow === 'function' + ? queryTabsInAutomationWindow + : (chrome?.tabs?.query ? (queryInfo) => chrome.tabs.query(queryInfo) : null); + if (typeof queryTabs !== 'function') { + return 0; + } + const tabs = await queryTabs({}).catch(() => []); + const candidates = (Array.isArray(tabs) ? tabs : []) + .filter((tab) => Number.isInteger(tab?.id) && isHostedCheckoutRuntimeUrl(tab.url || '')); + if (!candidates.length) { + return 0; + } + const match = candidates.find((tab) => tab.active && tab.currentWindow) + || candidates.find((tab) => tab.active) + || candidates[0]; + if (match?.id && chrome?.tabs?.update) { + await chrome.tabs.update(match.id, { active: true }).catch(() => {}); + } + await registerHostedCheckoutTab(match.id, match.url || ''); + return match?.id || 0; + } + + async function resolveHostedCheckoutTabId(state = {}, stepKey = '') { + const storedTabId = Number(state?.plusCheckoutTabId) || 0; + const storedTab = await getTabById(storedTabId); + if (storedTab?.id && isHostedCheckoutRuntimeUrl(storedTab.url || '')) { + await registerHostedCheckoutTab(storedTab.id, storedTab.url || ''); + return storedTab.id; + } + + if (typeof getTabId === 'function') { + const paypalTabId = await Promise.resolve(getTabId(PAYPAL_SOURCE)).catch(() => 0); + const paypalAlive = typeof isTabAlive !== 'function' + ? Boolean(paypalTabId) + : await Promise.resolve(isTabAlive(PAYPAL_SOURCE)).catch(() => false); + if (paypalTabId && paypalAlive) { + return paypalTabId; + } + const checkoutTabId = await Promise.resolve(getTabId(PLUS_CHECKOUT_SOURCE)).catch(() => 0); + const checkoutAlive = typeof isTabAlive !== 'function' + ? Boolean(checkoutTabId) + : await Promise.resolve(isTabAlive(PLUS_CHECKOUT_SOURCE)).catch(() => false); + if (checkoutTabId && checkoutAlive) { + return checkoutTabId; + } + } + + const discoveredTabId = await findOpenHostedCheckoutTabId(); + if (discoveredTabId) { + await addHostedStepLog(stepKey, `步骤 ${getHostedStepNumber(stepKey)}:已从当前浏览器标签中发现 PayPal 无卡直绑页面,正在接管继续执行。`, 'info'); + return discoveredTabId; + } + + throw new Error(`步骤 ${getHostedStepNumber(stepKey)}:未找到 PayPal 无卡直绑标签页,请先完成创建 checkout 节点。`); + } + + async function getHostedCurrentUrl(tabId) { + const tab = await getTabById(tabId); + return String(tab?.url || '').trim(); + } + + async function updateHostedCheckoutTabState(tabId, payload = {}) { + const currentUrl = await getHostedCurrentUrl(tabId); + await setState({ + plusCheckoutTabId: tabId, + plusCheckoutUrl: currentUrl, + ...(payload && typeof payload === 'object' ? payload : {}), + }); + return currentUrl; + } + + async function completeHostedStep(stepKey, tabId, payload = {}) { + const currentUrl = await updateHostedCheckoutTabState(tabId, payload); + await completeNodeFromBackground(stepKey, { + plusCheckoutUrl: currentUrl, + ...(payload && typeof payload === 'object' ? payload : {}), + }); + } + + async function completeHostedStepIfSuccessful(stepKey, tabId, state = {}, options = {}) { + const currentUrl = await getHostedCurrentUrl(tabId); + if (!isHostedCheckoutSuccessUrl(currentUrl)) { + return false; + } + const config = await getHostedCheckoutRuntimeConfig(state); + const shouldWait = Boolean(options.waitBeforeComplete); + if (shouldWait && config.oauthDelaySeconds > 0) { + await addHostedStepLog(stepKey, `步骤 ${getHostedStepNumber(stepKey)}:支付成功后等待 ${config.oauthDelaySeconds} 秒,再继续账号接入。`, 'info'); + await sleepWithStop(config.oauthDelaySeconds * 1000); + } + await completeHostedStep(stepKey, tabId, { + plusReturnUrl: currentUrl, + plusHostedCheckoutCompleted: true, + plusHostedCheckoutOauthDelaySeconds: config.oauthDelaySeconds, + }); + return true; + } + async function waitForUrlMatch(tabId, matcher, timeoutMs = 30000, retryDelayMs = 500) { const deadline = Date.now() + Math.max(1000, Number(timeoutMs) || 30000); while (Date.now() < deadline) { @@ -130,18 +336,18 @@ const latestState = typeof getState === 'function' ? await getState().catch(() => ({})) : {}; return { verificationUrl: String( - state?.hostedCheckoutVerificationUrl - || latestState?.hostedCheckoutVerificationUrl + latestState?.hostedCheckoutVerificationUrl + || state?.hostedCheckoutVerificationUrl || '' ).trim(), phone: String( - state?.hostedCheckoutPhoneNumber - || latestState?.hostedCheckoutPhoneNumber + latestState?.hostedCheckoutPhoneNumber + || state?.hostedCheckoutPhoneNumber || HOSTED_CHECKOUT_DEFAULT_PHONE ).trim(), oauthDelaySeconds: normalizeHostedCheckoutDelaySeconds( - state?.plusHostedCheckoutOauthDelaySeconds - ?? latestState?.plusHostedCheckoutOauthDelaySeconds + latestState?.plusHostedCheckoutOauthDelaySeconds + ?? state?.plusHostedCheckoutOauthDelaySeconds ), }; } @@ -386,72 +592,80 @@ return result || {}; } - async function runHostedPayPalCheckout(tabId, profile, config) { - const deadline = Date.now() + HOSTED_CHECKOUT_PAYPAL_TIMEOUT_MS; - while (Date.now() < deadline) { - throwIfStopped(); - const tab = await chrome?.tabs?.get?.(tabId).catch(() => null); - if (!tab) { - throw new Error('步骤 6:PayPal 无卡直绑标签页已关闭。'); - } - const currentUrl = String(tab.url || '').trim(); - if (isHostedCheckoutSuccessUrl(currentUrl)) { - await addLog('步骤 6:PayPal 无卡直绑已回到 ChatGPT 支付成功页。', 'ok'); - return currentUrl; - } - if (!isPayPalUrl(currentUrl)) { - await addLog(`步骤 6:无卡直绑已离开 PayPal(${currentUrl || '空 URL'}),继续等待支付成功页。`, 'info'); - await sleepWithStop(1000); - continue; - } - - const pageState = await getHostedPayPalState(tabId); - if (pageState.hostedStage === 'verification' && pageState.verificationInputsVisible) { - const verificationCode = await pollHostedVerificationCode(config.verificationUrl); - await runHostedPayPalStep(tabId, { verificationCode }); - await sleepWithStop(1000); - continue; - } - if (pageState.hostedStage === 'pay_login') { - await addLog('步骤 6:正在填写 PayPal 无卡直绑登录邮箱。', 'info'); - await runHostedPayPalStep(tabId, { email: profile.email }); - await sleepWithStop(1000); - continue; - } - if (pageState.hostedStage === 'guest_checkout') { - await addLog('步骤 6:正在填写 PayPal 无卡直绑卡支付资料。', 'info'); - await runHostedPayPalStep(tabId, { - ...profile, - phone: config.phone || profile.phone, - }); - await sleepWithStop(1500); - continue; - } - if (pageState.hostedStage === 'review_consent') { - await addLog('步骤 6:正在确认 PayPal 无卡直绑账单复核页。', 'info'); - await runHostedPayPalStep(tabId, {}); - await sleepWithStop(1000); - continue; - } - if (pageState.hostedStage === 'approval') { - throw new Error('步骤 6:无卡直绑流程进入普通 PayPal 授权页,请检查支付方式是否选择正确。'); - } - await sleepWithStop(1000); + function getHostedStageOrder(stage = '') { + switch (stage) { + case PAYPAL_HOSTED_STAGE_LOGIN: + return 1; + case PAYPAL_HOSTED_STAGE_VERIFICATION: + return 2; + case PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT: + return 3; + case PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT: + return 4; + case PAYPAL_HOSTED_STAGE_REVIEW: + return 5; + case PAYPAL_HOSTED_STAGE_OUTSIDE: + return 6; + default: + return 0; } - throw new Error('步骤 6:PayPal 无卡直绑自动化超时,未等到支付成功页。'); } - async function waitHostedSuccessDelayAndComplete(tabId, state = {}, successUrl = '') { - const config = await getHostedCheckoutRuntimeConfig(state); - if (config.oauthDelaySeconds > 0) { - await addLog(`步骤 6:支付成功后等待 ${config.oauthDelaySeconds} 秒,再继续账号接入。`, 'info'); - await sleepWithStop(config.oauthDelaySeconds * 1000); + function isHostedStageAtOrAfter(stage = '', expectedStage = '') { + const currentOrder = getHostedStageOrder(stage); + const expectedOrder = getHostedStageOrder(expectedStage); + return currentOrder > 0 && expectedOrder > 0 && currentOrder >= expectedOrder; + } + + async function waitForHostedPayPalStage(tabId, predicate, options = {}) { + const timeoutMs = Math.max(1000, Number(options.timeoutMs) || HOSTED_CHECKOUT_TRANSITION_TIMEOUT_MS); + const intervalMs = Math.max(100, Number(options.intervalMs) || 500); + const label = String(options.label || 'PayPal 无卡直绑页面').trim(); + const deadline = Date.now() + timeoutMs; + let lastStage = ''; + while (Date.now() < deadline) { + throwIfStopped(); + const currentUrl = await getHostedCurrentUrl(tabId); + if (isHostedCheckoutSuccessUrl(currentUrl)) { + return { + successUrl: currentUrl, + hostedStage: PAYPAL_HOSTED_STAGE_OUTSIDE, + }; + } + if (!isPayPalUrl(currentUrl)) { + await sleepWithStop(intervalMs); + continue; + } + try { + const pageState = await getHostedPayPalState(tabId); + lastStage = pageState?.hostedStage || lastStage; + if (predicate(pageState)) { + return pageState; + } + } catch (error) { + lastStage = error?.message || lastStage; + } + await sleepWithStop(intervalMs); } - await completeNodeFromBackground('plus-checkout-create', { - plusReturnUrl: successUrl, - plusHostedCheckoutCompleted: true, - plusHostedCheckoutOauthDelaySeconds: config.oauthDelaySeconds, - }); + throw new Error(`${label}等待超时${lastStage ? `(最后状态:${lastStage})` : ''}。`); + } + + async function waitForHostedUrlAfterAction(tabId, matcher, options = {}) { + const timeoutMs = Math.max(1000, Number(options.timeoutMs) || HOSTED_CHECKOUT_TRANSITION_TIMEOUT_MS); + const intervalMs = Math.max(100, Number(options.intervalMs) || 500); + const label = String(options.label || 'PayPal 无卡直绑跳转').trim(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + throwIfStopped(); + const currentTab = await getTabById(tabId); + const currentUrl = String(currentTab?.url || '').trim(); + if (matcher(currentUrl, currentTab)) { + await waitForTabCompleteUntilStopped(tabId).catch(() => {}); + return currentUrl; + } + await sleepWithStop(intervalMs); + } + throw new Error(`${label}等待超时。`); } function resolveCheckoutTargetUrl(result = {}, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) { @@ -484,56 +698,298 @@ ); const landedUrl = String(landedTab?.url || targetCheckoutUrl || '').trim(); + const isAlreadySuccessful = isHostedCheckoutSuccessUrl(landedUrl); await setState({ plusCheckoutTabId: tabId, plusCheckoutUrl: landedUrl, plusCheckoutCountry: result.country || 'US', plusCheckoutCurrency: result.currency || 'USD', plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED, - plusReturnUrl: '', + plusReturnUrl: isAlreadySuccessful ? landedUrl : '', + plusHostedCheckoutCompleted: isAlreadySuccessful, }); - await addLog(`步骤 6:PayPal 无卡直绑页面已就绪(${result.country || 'US'} ${result.currency || 'USD'}),开始自动完成直绑支付链路。`, 'info'); + await addLog(`步骤 6:PayPal 无卡直绑页面已就绪(${result.country || 'US'} ${result.currency || 'USD'}),准备进入页面级直绑节点。`, 'info'); - if (isHostedCheckoutSuccessUrl(landedUrl)) { - await waitHostedSuccessDelayAndComplete(tabId, state, landedUrl); + await completeNodeFromBackground('plus-checkout-create', { + plusCheckoutCountry: result.country || 'US', + plusCheckoutCurrency: result.currency || 'USD', + plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED, + plusCheckoutUrl: landedUrl, + plusReturnUrl: isAlreadySuccessful ? landedUrl : '', + plusHostedCheckoutCompleted: isAlreadySuccessful, + }); + } + + async function executePayPalHostedOpenAiCheckout(state = {}) { + const stepKey = PAYPAL_HOSTED_STEP_OPENAI_CHECKOUT; + const stepNumber = getHostedStepNumber(stepKey); + const tabId = await resolveHostedCheckoutTabId(state, stepKey); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { return; } + let currentUrl = await getHostedCurrentUrl(tabId); + if (isPayPalUrl(currentUrl)) { + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前已在 PayPal 页面,OpenAI Checkout 节点直接完成。`, 'info'); + await completeHostedStep(stepKey, tabId, { + plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED, + }); + return; + } + if (!isHostedOpenAiCheckoutUrl(currentUrl)) { + currentUrl = await waitForHostedUrlAfterAction( + tabId, + (url) => isHostedOpenAiCheckoutUrl(url) || isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url), + { label: `步骤 ${stepNumber}:等待 OpenAI hosted checkout 页面` } + ); + } + if (isHostedCheckoutSuccessUrl(currentUrl)) { + await completeHostedStep(stepKey, tabId, { + plusReturnUrl: currentUrl, + plusHostedCheckoutCompleted: true, + }); + return; + } + if (isPayPalUrl(currentUrl)) { + await completeHostedStep(stepKey, tabId, { + plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED, + }); + return; + } + + const { profile, config } = await ensureHostedGuestProfile(state); + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在选择 PayPal 并提交 OpenAI hosted checkout(电话使用本地号码 ${profile.phone})。`, 'info'); + const transitionUrl = await runHostedOpenAiCheckout(tabId, profile, config); + const completedUrl = String(transitionUrl || await getHostedCurrentUrl(tabId) || '').trim(); + await completeHostedStep(stepKey, tabId, { + plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED, + plusCheckoutUrl: completedUrl, + plusReturnUrl: isHostedCheckoutSuccessUrl(completedUrl) ? completedUrl : '', + plusHostedCheckoutCompleted: isHostedCheckoutSuccessUrl(completedUrl), + }); + } + + async function executePayPalHostedEmail(state = {}) { + const stepKey = PAYPAL_HOSTED_STEP_EMAIL; + const stepNumber = getHostedStepNumber(stepKey); + const tabId = await resolveHostedCheckoutTabId(state, stepKey); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { + return; + } + const { profile } = await ensureHostedGuestProfile(state); + await waitForHostedUrlAfterAction( + tabId, + (url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url), + { label: `步骤 ${stepNumber}:等待 PayPal 邮箱页` } + ); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { + return; + } + + const pageState = await getHostedPayPalState(tabId); + if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_VERIFICATION) + && pageState.hostedStage !== PAYPAL_HOSTED_STAGE_LOGIN) { + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),邮箱节点直接完成。`, 'info'); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: pageState.hostedStage, + }); + return; + } + if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_LOGIN) { + throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 邮箱页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`); + } + + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在填写 PayPal 无卡直绑邮箱。`, 'info'); + await runHostedPayPalStep(tabId, { + expectedStage: PAYPAL_HOSTED_STAGE_LOGIN, + email: profile.email, + }); + const nextState = await waitForHostedPayPalStage( + tabId, + (stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_LOGIN, + { label: `步骤 ${stepNumber}:等待 PayPal 邮箱页跳转` } + ); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: nextState.hostedStage || '', + }); + } + + async function executePayPalHostedVerification(state = {}) { + const stepKey = PAYPAL_HOSTED_STEP_VERIFICATION; + const stepNumber = getHostedStepNumber(stepKey); + const tabId = await resolveHostedCheckoutTabId(state, stepKey); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { + return; + } + await waitForHostedUrlAfterAction( + tabId, + (url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url), + { label: `步骤 ${stepNumber}:等待 PayPal 验证码页` } + ); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { + return; + } + + const pageState = await getHostedPayPalState(tabId); + if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) + && pageState.hostedStage !== PAYPAL_HOSTED_STAGE_VERIFICATION) { + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),验证码节点直接完成。`, 'info'); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: pageState.hostedStage, + }); + return; + } + if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_VERIFICATION) { + throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 验证码页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`); + } + const config = await getHostedCheckoutRuntimeConfig(state); - const address = await fetchHostedCheckoutAddress(); - const guestProfile = buildHostedGuestProfile(address, config); - let transitionUrl = landedUrl; + const verificationCode = await pollHostedVerificationCode(config.verificationUrl); + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在填写 PayPal 无卡直绑验证码。`, 'info'); + await runHostedPayPalStep(tabId, { + expectedStage: PAYPAL_HOSTED_STAGE_VERIFICATION, + verificationCode, + }); + const nextState = await waitForHostedPayPalStage( + tabId, + (stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_VERIFICATION, + { label: `步骤 ${stepNumber}:等待 PayPal 验证码页跳转` } + ); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: nextState.hostedStage || '', + }); + } - if (isHostedOpenAiCheckoutUrl(transitionUrl)) { - transitionUrl = await runHostedOpenAiCheckout(tabId, guestProfile, config); + async function executePayPalHostedCard(state = {}) { + const stepKey = PAYPAL_HOSTED_STEP_CARD; + const stepNumber = getHostedStepNumber(stepKey); + const tabId = await resolveHostedCheckoutTabId(state, stepKey); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { + return; } - - if (isHostedCheckoutSuccessUrl(transitionUrl)) { - await waitHostedSuccessDelayAndComplete(tabId, state, transitionUrl); + await waitForHostedUrlAfterAction( + tabId, + (url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url), + { label: `步骤 ${stepNumber}:等待 PayPal 资料页` } + ); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { return; } - if (!isPayPalUrl(transitionUrl)) { - const transitionTab = await waitForUrlMatch( - tabId, - (url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url), - HOSTED_CHECKOUT_TRANSITION_TIMEOUT_MS, - 500 + const pageState = await getHostedPayPalState(tabId); + if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) + && pageState.hostedStage !== PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) { + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),资料节点直接完成。`, 'info'); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: pageState.hostedStage, + }); + return; + } + if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) { + throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 资料页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`); + } + + const { profile } = await ensureHostedGuestProfile(state); + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在填写 PayPal 无卡直绑资料,提交前会复查电话是否为 ${profile.phone}。`, 'info'); + const cardResult = await runHostedPayPalStep(tabId, { + ...profile, + expectedStage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT, + phone: profile.phone, + }); + if (cardResult?.phoneMatched) { + await addHostedStepLog( + stepKey, + `步骤 ${stepNumber}:PayPal 页面电话复查通过(配置 ${cardResult.payloadPhoneDigits},页面 ${cardResult.renderedPhoneDigits})。`, + 'info' ); - transitionUrl = String(transitionTab?.url || '').trim(); } + const nextState = await waitForHostedPayPalStage( + tabId, + (stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT, + { label: `步骤 ${stepNumber}:等待 PayPal 资料页跳转` } + ); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: nextState.hostedStage || '', + }); + } - if (isHostedCheckoutSuccessUrl(transitionUrl)) { - await waitHostedSuccessDelayAndComplete(tabId, state, transitionUrl); + async function executePayPalHostedCreateAccount(state = {}) { + const stepKey = PAYPAL_HOSTED_STEP_CREATE_ACCOUNT; + const stepNumber = getHostedStepNumber(stepKey); + const tabId = await resolveHostedCheckoutTabId(state, stepKey); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { return; } - if (!isPayPalUrl(transitionUrl)) { - throw new Error('步骤 6:PayPal 无卡直绑提交后未进入 PayPal 或支付成功页。'); + await waitForHostedUrlAfterAction( + tabId, + (url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url), + { label: `步骤 ${stepNumber}:等待 PayPal 创建确认页` } + ); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) { + return; } - const successUrl = await runHostedPayPalCheckout(tabId, guestProfile, config); - await waitHostedSuccessDelayAndComplete(tabId, state, successUrl); + const pageState = await getHostedPayPalState(tabId); + if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_REVIEW) + && pageState.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) { + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),创建确认节点直接完成。`, 'info'); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: pageState.hostedStage, + }); + return; + } + if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) { + throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 创建确认页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`); + } + + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在确认创建 PayPal 账号。`, 'info'); + await runHostedPayPalStep(tabId, { + expectedStage: PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT, + }); + const nextState = await waitForHostedPayPalStage( + tabId, + (stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT, + { label: `步骤 ${stepNumber}:等待 PayPal 创建确认页跳转` } + ); + await completeHostedStep(stepKey, tabId, { + plusHostedCheckoutLastStage: nextState.hostedStage || '', + }); + } + + async function executePayPalHostedReview(state = {}) { + const stepKey = PAYPAL_HOSTED_STEP_REVIEW; + const stepNumber = getHostedStepNumber(stepKey); + const tabId = await resolveHostedCheckoutTabId(state, stepKey); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state, { waitBeforeComplete: true })) { + return; + } + await waitForHostedUrlAfterAction( + tabId, + (url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url), + { label: `步骤 ${stepNumber}:等待 PayPal 授权复核页` } + ); + if (await completeHostedStepIfSuccessful(stepKey, tabId, state, { waitBeforeComplete: true })) { + return; + } + + const pageState = await getHostedPayPalState(tabId); + if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_REVIEW) { + throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 授权复核页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`); + } + + await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在确认 PayPal 授权复核页。`, 'info'); + await runHostedPayPalStep(tabId, { + expectedStage: PAYPAL_HOSTED_STAGE_REVIEW, + }); + await waitForHostedUrlAfterAction( + tabId, + (url) => isHostedCheckoutSuccessUrl(url), + { label: `步骤 ${stepNumber}:等待 PayPal 回到 ChatGPT 支付成功页`, timeoutMs: HOSTED_CHECKOUT_PAYPAL_TIMEOUT_MS } + ); + if (!await completeHostedStepIfSuccessful(stepKey, tabId, state, { waitBeforeComplete: true })) { + throw new Error(`步骤 ${stepNumber}:PayPal 授权后未检测到 ChatGPT 支付成功页。`); + } } function normalizeHelperCountryCode(countryCode = '86') { @@ -990,6 +1446,12 @@ return { executePlusCheckoutCreate, + executePayPalHostedOpenAiCheckout, + executePayPalHostedEmail, + executePayPalHostedVerification, + executePayPalHostedCard, + executePayPalHostedCreateAccount, + executePayPalHostedReview, }; } diff --git a/content/paypal-flow.js b/content/paypal-flow.js index 9fd8782..a1d844b 100644 --- a/content/paypal-flow.js +++ b/content/paypal-flow.js @@ -8,9 +8,17 @@ 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_VERIFICATION = 'verification'; +const PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT = 'create_account'; const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent'; const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval'; const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown'; +const PAYPAL_HOSTED_STEP_KEYS = { + [PAYPAL_HOSTED_STAGE_LOGIN]: 'paypal-hosted-email', + [PAYPAL_HOSTED_STAGE_VERIFICATION]: 'paypal-hosted-verification', + [PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT]: 'paypal-hosted-card', + [PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT]: 'paypal-hosted-create-account', + [PAYPAL_HOSTED_STAGE_REVIEW]: 'paypal-hosted-review', +}; if (document.documentElement.getAttribute(PAYPAL_FLOW_LISTENER_SENTINEL) !== '1') { document.documentElement.setAttribute(PAYPAL_FLOW_LISTENER_SENTINEL, '1'); @@ -255,15 +263,47 @@ function isHostedLoginPage() { } function isHostedGuestCheckoutPage() { + if (document.getElementById('cardNumber') || document.getElementById('billingLine1')) { + return true; + } + const pageText = normalizeText(document.body?.innerText || document.body?.textContent || ''); + if (/create\s*(?:paypal\s*)?account|agree\s*(?:&|and)?\s*create|创建.*(?:账户|账号)/i.test(pageText) + && findHostedCreateAccountButton()) { + return false; + } return /\/checkoutweb\//i.test(getPayPalPathname()) - || Boolean(document.getElementById('cardNumber')) - || Boolean(document.getElementById('billingLine1')); + && Boolean(document.getElementById('phone') || document.getElementById('email')); } function isHostedReviewPage() { return /\/webapps\/hermes/i.test(getPayPalPathname()); } +function findHostedCreateAccountButton() { + return document.getElementById('createAccount') + || document.getElementById('createAccountButton') + || document.querySelector('button[data-testid="createAccountButton"]') + || document.querySelector('button[data-testid="create-account-button"]') + || findClickableByText([ + /agree\s*(?:&|and)?\s*create\s*(?:paypal\s*)?account/i, + /create\s*(?:paypal\s*)?account/i, + /同意.*创建|创建.*账户|创建.*账号/i, + ]); +} + +function isHostedCreateAccountPage() { + if (document.getElementById('cardNumber') || document.getElementById('billingLine1')) { + return false; + } + const button = findHostedCreateAccountButton(); + if (!button || !isVisibleElement(button) || !isEnabledControl(button)) { + return false; + } + const pageText = normalizeText(document.body?.innerText || document.body?.textContent || ''); + return /create\s*(?:paypal\s*)?account|agree\s*(?:&|and)?\s*create|创建.*(?:账户|账号)/i.test(pageText) + || /create/i.test(getActionText(button)); +} + function findHostedReviewConsentButton() { const direct = document.getElementById('consentButton') || document.querySelector('button[data-testid="consentButton"]'); @@ -289,6 +329,9 @@ function detectPayPalHostedStage() { if (isHostedReviewPage() && findHostedReviewConsentButton()) { return PAYPAL_HOSTED_STAGE_REVIEW; } + if (isHostedCreateAccountPage()) { + return PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT; + } if (isHostedLoginPage()) { return PAYPAL_HOSTED_STAGE_LOGIN; } @@ -337,23 +380,102 @@ function findHostedSubmitButton() { ]); } -async function clickHostedSubmitButton() { +function getHostedStepKey(stage = '', fallback = 'plus-checkout-create') { + return PAYPAL_HOSTED_STEP_KEYS[stage] || fallback; +} + +async function clickHostedSubmitButton(options = {}) { + const stepKey = String(options.stepKey || getHostedStepKey(options.stage)).trim(); + const label = String(options.label || 'hosted-paypal-submit').trim(); + const maxAttempts = Math.max(1, Math.floor(Number(options.maxAttempts) || 3)); + let lastButtonText = ''; + let lastDisabled = false; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const button = await waitUntil(() => { + const candidate = findHostedSubmitButton(); + return candidate && isVisibleElement(candidate) ? candidate : null; + }, { + intervalMs: 500, + timeoutMs: 15000, + timeoutMessage: 'PayPal hosted checkout 未找到可点击的继续/提交按钮。', + }); + lastButtonText = getActionText(button); + lastDisabled = !isEnabledControl(button); + if (lastDisabled) { + if (attempt >= maxAttempts) { + throw new Error('PayPal hosted checkout 继续/提交按钮长时间不可用。'); + } + await sleep(1000); + continue; + } + + await performPayPalOperationWithDelay({ stepKey, kind: 'click', label }, async () => { + simulateClick(button); + }); + await sleep(1000); + return { + clicked: true, + buttonText: lastButtonText, + verificationRequired: hasHostedVerificationInputs(), + attempt, + }; + } + return { + clicked: false, + buttonText: lastButtonText, + disabled: lastDisabled, + }; +} + +function normalizeHostedPhoneDigits(value = '') { + return String(value || '').replace(/\D/g, ''); +} + +function verifyHostedPhoneBeforeSubmit(expectedPhone = '') { + const phoneInput = document.getElementById('phone'); + if (!phoneInput || !isVisibleElement(phoneInput)) { + throw new Error('PayPal hosted checkout 未找到电话输入框。'); + } + const expectedDigits = normalizeHostedPhoneDigits(expectedPhone || PAYPAL_HOSTED_DEFAULT_PHONE); + const renderedDigits = normalizeHostedPhoneDigits(phoneInput.value || ''); + if (!expectedDigits) { + throw new Error('PayPal hosted checkout 电话配置为空。'); + } + const comparableRenderedDigits = renderedDigits.length > expectedDigits.length + ? renderedDigits.slice(-expectedDigits.length) + : renderedDigits; + if (comparableRenderedDigits !== expectedDigits) { + throw new Error(`PayPal hosted checkout 电话不一致:配置 ${expectedDigits},页面 ${renderedDigits || '(空)'}。`); + } + return { + payloadPhoneDigits: expectedDigits, + renderedPhoneDigits: renderedDigits, + phoneMatched: true, + }; +} + +async function clickHostedCreateAccount(payload = {}) { + await waitForDocumentComplete(); const button = await waitUntil(() => { - const candidate = findHostedSubmitButton(); + const candidate = findHostedCreateAccountButton(); return candidate && isVisibleElement(candidate) && isEnabledControl(candidate) ? candidate : null; }, { intervalMs: 500, - timeoutMs: 15000, - timeoutMessage: 'PayPal hosted checkout 未找到可点击的继续/提交按钮。', + timeoutMs: 30000, + timeoutMessage: 'PayPal hosted checkout 未找到创建账号确认按钮。', }); - await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'click', label: 'hosted-paypal-submit' }, async () => { + await performPayPalOperationWithDelay({ + stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT), + kind: 'click', + label: 'hosted-paypal-create-account', + }, async () => { simulateClick(button); }); - await sleep(1000); return { + stage: PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT, clicked: true, + submitted: true, buttonText: getActionText(button), - verificationRequired: hasHostedVerificationInputs(), }; } @@ -408,7 +530,10 @@ async function submitHostedLogin(payload = {}) { throw new Error('PayPal hosted checkout 未找到邮箱输入框。'); } refillPayPalEmailInput(emailInput, email); - const clickResult = await clickHostedSubmitButton(); + const clickResult = await clickHostedSubmitButton({ + stage: PAYPAL_HOSTED_STAGE_LOGIN, + label: 'hosted-paypal-email-submit', + }); return { stage: PAYPAL_HOSTED_STAGE_LOGIN, submitted: true, @@ -427,7 +552,11 @@ async function fillHostedVerificationCode(payload = {}) { if (inputs.length < 6) { throw new Error('PayPal hosted checkout 当前页面未显示验证码输入框。'); } - await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'fill', label: 'hosted-paypal-verification-code' }, async () => { + await performPayPalOperationWithDelay({ + stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_VERIFICATION), + kind: 'fill', + label: 'hosted-paypal-verification-code', + }, async () => { inputs.forEach((input, index) => fillInput(input, code[index] || '')); }); return { @@ -468,11 +597,18 @@ async function fillHostedGuestCheckout(payload = {}) { fillHostedInputById('billingCity', address.city || ''); fillHostedInputById('billingPostalCode', address.zip || address.postalCode || ''); selectHostedOptionByIdText('billingState', address.state || address.region || ''); - const clickResult = await clickHostedSubmitButton(); + const phoneCheck = verifyHostedPhoneBeforeSubmit(values.phone); + const clickResult = await clickHostedSubmitButton({ + stage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT, + label: 'hosted-paypal-card-submit', + maxAttempts: 4, + }); return { stage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT, submitted: true, verificationRequired: Boolean(clickResult.verificationRequired), + payloadPhone: values.phone, + ...phoneCheck, }; } @@ -486,7 +622,11 @@ async function clickHostedReviewConsent() { timeoutMs: 30000, timeoutMessage: 'PayPal hosted checkout 未找到账单确认按钮。', }); - await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'click', label: 'hosted-paypal-review-consent' }, async () => { + await performPayPalOperationWithDelay({ + stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_REVIEW), + kind: 'click', + label: 'hosted-paypal-review-consent', + }, async () => { simulateClick(button); }); return { @@ -497,6 +637,16 @@ async function clickHostedReviewConsent() { async function runPayPalHostedCheckoutStep(payload = {}) { const stage = detectPayPalHostedStage(); + const expectedStage = String(payload.expectedStage || '').trim(); + if (expectedStage && stage !== expectedStage) { + return { + stage, + expectedStage, + submitted: false, + skipped: true, + approveReady: Boolean(findApproveButton()), + }; + } if (stage === PAYPAL_HOSTED_STAGE_VERIFICATION) { return payload.verificationCode || payload.code ? fillHostedVerificationCode(payload) @@ -508,6 +658,9 @@ async function runPayPalHostedCheckoutStep(payload = {}) { if (stage === PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) { return fillHostedGuestCheckout(payload); } + if (stage === PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) { + return clickHostedCreateAccount(payload); + } if (stage === PAYPAL_HOSTED_STAGE_REVIEW) { return clickHostedReviewConsent(); } @@ -520,6 +673,7 @@ async function runPayPalHostedCheckoutStep(payload = {}) { function inspectPayPalHostedState() { const stage = detectPayPalHostedStage(); + const createAccountButton = findHostedCreateAccountButton(); return { url: location.href, readyState: document.readyState, @@ -527,6 +681,7 @@ function inspectPayPalHostedState() { verificationInputsVisible: hasHostedVerificationInputs(), hasGuestCardFields: Boolean(document.getElementById('cardNumber')), hasHostedEmailInput: Boolean(document.getElementById('email') || findEmailInput()), + createAccountReady: Boolean(createAccountButton && isVisibleElement(createAccountButton) && isEnabledControl(createAccountButton)), reviewConsentReady: Boolean(findHostedReviewConsentButton()), approveReady: Boolean(findApproveButton()), bodyTextPreview: normalizeText(document.body?.innerText || '').slice(0, 240), diff --git a/data/step-definitions.js b/data/step-definitions.js index e7b2a8d..909d31b 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -33,7 +33,15 @@ { id: 8, order: 80, key: 'paypal-approve', title: 'PayPal 登录与授权', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-approve' }, { id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-return' }, ]; - const PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS = PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS.slice(0, 6); + const PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS = [ + ...PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS.slice(0, 6), + { id: 7, order: 70, key: 'paypal-hosted-openai-checkout', title: '无卡直绑提交 OpenAI Checkout', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'paypal-hosted-openai-checkout' }, + { id: 8, order: 80, key: 'paypal-hosted-email', title: '无卡直绑填写 PayPal 邮箱', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-email' }, + { id: 9, order: 90, key: 'paypal-hosted-verification', title: '无卡直绑填写 PayPal 验证码', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-verification' }, + { id: 10, order: 100, key: 'paypal-hosted-card', title: '无卡直绑填写 PayPal 资料', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-card' }, + { id: 11, order: 110, key: 'paypal-hosted-create-account', title: '无卡直绑确认创建 PayPal', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-create-account' }, + { id: 12, order: 120, key: 'paypal-hosted-review', title: '无卡直绑完成 PayPal 授权', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-review' }, + ]; const PLUS_GOPAY_PREFIX_STEP_DEFINITIONS = [ { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' }, @@ -232,23 +240,23 @@ ); const PLUS_PAYPAL_PHONE_STEP_DEFINITIONS = createOpenAiSteps(PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE); const PLUS_PAYPAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createOpenAiSteps(PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true }); - const PLUS_PAYPAL_HOSTED_CHECKOUT_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 7, 70, SIGNUP_METHOD_EMAIL); + const PLUS_PAYPAL_HOSTED_CHECKOUT_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 13, 130, SIGNUP_METHOD_EMAIL); const PLUS_PAYPAL_HOSTED_CHECKOUT_SUB2API_SESSION_STEP_DEFINITIONS = createHostedCheckoutSteps( PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, - 7, - 70, + 13, + 130, SIGNUP_METHOD_EMAIL, { plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION } ); const PLUS_PAYPAL_HOSTED_CHECKOUT_CPA_SESSION_STEP_DEFINITIONS = createHostedCheckoutSteps( PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, - 7, - 70, + 13, + 130, SIGNUP_METHOD_EMAIL, { plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION } ); - const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 7, 70, SIGNUP_METHOD_PHONE); - const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 7, 70, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true }); + const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 13, 130, SIGNUP_METHOD_PHONE); + const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 13, 130, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true }); const PLUS_GOPAY_STEP_DEFINITIONS = createOpenAiSteps(PLUS_GOPAY_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_EMAIL); const PLUS_GOPAY_SUB2API_SESSION_STEP_DEFINITIONS = createOpenAiSteps( PLUS_GOPAY_PREFIX_STEP_DEFINITIONS, diff --git a/shared/flow-registry.js b/shared/flow-registry.js index a9a46ad..8f31c82 100644 --- a/shared/flow-registry.js +++ b/shared/flow-registry.js @@ -191,11 +191,23 @@ }, 'content/plus-checkout': { sourceId: 'plus-checkout', - commands: ['plus-checkout-create', 'plus-checkout-billing', 'plus-checkout-return'], + commands: [ + 'plus-checkout-create', + 'paypal-hosted-openai-checkout', + 'plus-checkout-billing', + 'plus-checkout-return', + ], }, 'content/paypal-flow': { sourceId: 'paypal-flow', - commands: ['paypal-approve'], + commands: [ + 'paypal-approve', + 'paypal-hosted-email', + 'paypal-hosted-verification', + 'paypal-hosted-card', + 'paypal-hosted-create-account', + 'paypal-hosted-review', + ], }, 'content/gopay-flow': { sourceId: 'gopay-flow', diff --git a/tests/paypal-flow-content.test.js b/tests/paypal-flow-content.test.js index f5995f1..f6cc5f3 100644 --- a/tests/paypal-flow-content.test.js +++ b/tests/paypal-flow-content.test.js @@ -1,6 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); +const vm = require('node:vm'); const source = fs.readFileSync('content/paypal-flow.js', 'utf8'); @@ -308,3 +309,332 @@ test('PayPal email submit refills a prefilled email before clicking next', async awaiting: 'password_page', }); }); + +function createHostedPayPalHarness(options = {}) { + const events = []; + const attrs = new Map(); + const elementsById = new Map(); + let elements = []; + let listener = null; + const body = { innerText: '', textContent: '' }; + const location = { + href: 'https://www.paypal.com/checkoutweb/signup', + host: 'www.paypal.com', + pathname: '/checkoutweb/signup', + }; + + function createDomElement({ + tagName = 'DIV', + id = '', + type = '', + name = '', + text = '', + value = '', + attrs: initialAttrs = {}, + options: selectOptions = [], + } = {}) { + const attrMap = new Map(Object.entries(initialAttrs)); + const element = { + nodeType: 1, + tagName, + id, + type, + name, + textContent: text, + innerText: text, + value, + checked: false, + disabled: false, + hidden: false, + options: selectOptions, + parentElement: null, + style: { display: 'block', visibility: 'visible', opacity: '1' }, + getAttribute(key) { + if (key === 'id') return this.id; + if (key === 'type') return this.type; + if (key === 'name') return this.name; + if (key === 'placeholder') return attrMap.get('placeholder') || ''; + return attrMap.has(key) ? attrMap.get(key) : null; + }, + setAttribute(key, nextValue) { + attrMap.set(key, String(nextValue)); + }, + dispatchEvent() { + return true; + }, + focus() {}, + blur() {}, + click() { + events.push({ type: 'native-click', id: this.id, text: this.textContent }); + }, + getBoundingClientRect() { + return { left: 10, top: 10, width: 180, height: 44 }; + }, + }; + if (id) elementsById.set(id, element); + return element; + } + + const countrySelect = createDomElement({ tagName: 'SELECT', id: 'country', value: 'US' }); + const emailInput = createDomElement({ tagName: 'INPUT', id: 'email', type: 'email', name: 'email' }); + const phoneInput = createDomElement({ tagName: 'INPUT', id: 'phone', type: 'tel', name: 'phone' }); + const cardNumberInput = createDomElement({ tagName: 'INPUT', id: 'cardNumber', type: 'text' }); + const cardExpiryInput = createDomElement({ tagName: 'INPUT', id: 'cardExpiry', type: 'text' }); + const cardCvvInput = createDomElement({ tagName: 'INPUT', id: 'cardCvv', type: 'text' }); + const passwordInput = createDomElement({ tagName: 'INPUT', id: 'password', type: 'password' }); + const firstNameInput = createDomElement({ tagName: 'INPUT', id: 'firstName', type: 'text' }); + const lastNameInput = createDomElement({ tagName: 'INPUT', id: 'lastName', type: 'text' }); + const billingLine1Input = createDomElement({ tagName: 'INPUT', id: 'billingLine1', type: 'text' }); + const billingCityInput = createDomElement({ tagName: 'INPUT', id: 'billingCity', type: 'text' }); + const billingPostalCodeInput = createDomElement({ tagName: 'INPUT', id: 'billingPostalCode', type: 'text' }); + const billingStateSelect = createDomElement({ + tagName: 'SELECT', + id: 'billingState', + value: '', + options: [ + { textContent: 'New York', label: 'New York', value: 'NY' }, + { textContent: 'California', label: 'California', value: 'CA' }, + ], + }); + const submitButton = createDomElement({ + tagName: 'BUTTON', + id: 'hostedSubmit', + text: 'Agree & Create Account', + attrs: { 'data-testid': 'submit-button' }, + }); + const createAccountButton = createDomElement({ + tagName: 'BUTTON', + id: 'createAccountButton', + text: 'Agree & Create Account', + attrs: { 'data-testid': 'createAccountButton' }, + }); + + function setElements(nextElements) { + elements = nextElements; + elementsById.clear(); + for (const element of nextElements) { + if (element.id) elementsById.set(element.id, element); + } + } + + function showGuestCheckout() { + location.href = 'https://www.paypal.com/checkoutweb/signup'; + location.host = 'www.paypal.com'; + location.pathname = '/checkoutweb/signup'; + body.innerText = 'Pay with debit or credit card'; + body.textContent = body.innerText; + setElements([ + countrySelect, + emailInput, + phoneInput, + cardNumberInput, + cardExpiryInput, + cardCvvInput, + passwordInput, + firstNameInput, + lastNameInput, + billingLine1Input, + billingCityInput, + billingPostalCodeInput, + billingStateSelect, + submitButton, + ]); + } + + function showCreateAccount() { + location.href = 'https://www.paypal.com/checkoutweb/create-account'; + location.host = 'www.paypal.com'; + location.pathname = '/checkoutweb/create-account'; + body.innerText = 'Create your PayPal account. Agree & Create Account'; + body.textContent = body.innerText; + setElements([createAccountButton]); + } + + const context = { + console: { log() {}, warn() {}, error() {}, info() {} }, + location, + window: {}, + Event: class TestEvent { constructor(type) { this.type = type; } }, + MouseEvent: class TestMouseEvent { constructor(type) { this.type = type; } }, + PointerEvent: class TestPointerEvent { constructor(type) { this.type = type; } }, + document: { + readyState: 'complete', + body, + documentElement: { + getAttribute(name) { + return attrs.get(name) || null; + }, + setAttribute(name, nextValue) { + attrs.set(name, String(nextValue)); + }, + }, + getElementById(id) { + return elementsById.get(id) || null; + }, + querySelector(selector) { + const text = String(selector || ''); + if (text.includes('createAccountButton') || text.includes('create-account-button')) { + return elements.includes(createAccountButton) ? createAccountButton : null; + } + if (text.includes('submit-button') || text.includes('hosted-payment-submit-button')) { + return elements.includes(submitButton) ? submitButton : null; + } + return null; + }, + querySelectorAll(selector) { + const text = String(selector || ''); + if (text === 'input') return elements.filter((element) => element.tagName === 'INPUT'); + if (text === 'input[type="email"]') return elements.filter((element) => element.type === 'email'); + if (text === 'input[type="password"]') return elements.filter((element) => element.type === 'password'); + if (text.includes('button') || text.includes('[role="button"]')) { + return elements.filter((element) => element.tagName === 'BUTTON'); + } + return []; + }, + }, + chrome: { + runtime: { + onMessage: { + addListener(fn) { + listener = fn; + }, + }, + }, + }, + CodexOperationDelay: { + async performOperationWithDelay(metadata, operation) { + events.push({ type: 'operation', metadata }); + const result = await operation(); + events.push({ type: 'delay', metadata }); + return result; + }, + }, + resetStopState() {}, + isStopError() { return false; }, + throwIfStopped() {}, + sleep() { return Promise.resolve(); }, + fillInput(element, value) { + if (element === phoneInput && typeof options.renderPhone === 'function') { + element.value = options.renderPhone(value); + } else { + element.value = value; + } + events.push({ type: 'fill', id: element.id, value: element.value }); + }, + simulateClick(element) { + events.push({ type: 'click', id: element.id, text: element.textContent }); + }, + }; + context.window = context; + context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible', opacity: '1' }; + + vm.createContext(context); + vm.runInContext(source, context); + assert.equal(typeof listener, 'function'); + + async function send(message) { + return await new Promise((resolve) => { + listener(message, {}, resolve); + }); + } + + return { + events, + send, + showCreateAccount, + showGuestCheckout, + }; +} + +test('PayPal hosted guest checkout verifies configured local phone before submit', async () => { + const harness = createHostedPayPalHarness({ + renderPhone: (value) => `+1 ${value}`, + }); + harness.showGuestCheckout(); + + const result = await harness.send({ + type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP', + source: 'test', + payload: { + expectedStage: 'guest_checkout', + email: 'guest@example.com', + phone: '4155551234', + cardNumber: '4147200000000000', + cardExpiry: '12 / 29', + cardCvv: '123', + password: 'Aa1!example', + firstName: 'James', + lastName: 'Smith', + address: { + street: '1 Main St', + city: 'New York', + state: 'New York', + zip: '10001', + }, + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.submitted, true); + assert.equal(result.phoneMatched, true); + assert.equal(result.payloadPhoneDigits, '4155551234'); + assert.equal(result.renderedPhoneDigits, '14155551234'); + assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'hostedSubmit'), true); + assert.deepEqual( + JSON.parse(JSON.stringify(harness.events.filter((event) => event.type === 'operation').map((event) => event.metadata))), + [{ stepKey: 'paypal-hosted-card', kind: 'click', label: 'hosted-paypal-card-submit' }] + ); +}); + +test('PayPal hosted guest checkout blocks submit when rendered phone differs from config', async () => { + const harness = createHostedPayPalHarness({ + renderPhone: () => '9999999999', + }); + harness.showGuestCheckout(); + + const result = await harness.send({ + type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP', + source: 'test', + payload: { + expectedStage: 'guest_checkout', + phone: '4155551234', + cardNumber: '4147200000000000', + cardExpiry: '12 / 29', + cardCvv: '123', + password: 'Aa1!example', + address: { street: '1 Main St', city: 'New York', state: 'New York', zip: '10001' }, + }, + }); + + assert.match(result.error, /电话不一致/); + assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'hostedSubmit'), false); +}); + +test('PayPal hosted create account page is detected and handled as its own step', async () => { + const harness = createHostedPayPalHarness(); + harness.showCreateAccount(); + + const state = await harness.send({ + type: 'PAYPAL_HOSTED_GET_STATE', + source: 'test', + payload: {}, + }); + assert.equal(state.ok, true); + assert.equal(state.hostedStage, 'create_account'); + assert.equal(state.createAccountReady, true); + + const result = await harness.send({ + type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP', + source: 'test', + payload: { expectedStage: 'create_account' }, + }); + + assert.equal(result.ok, true); + assert.equal(result.stage, 'create_account'); + assert.equal(result.submitted, true); + assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'createAccountButton'), true); + assert.deepEqual( + JSON.parse(JSON.stringify(harness.events.filter((event) => event.type === 'operation').map((event) => event.metadata))), + [{ stepKey: 'paypal-hosted-create-account', kind: 'click', label: 'hosted-paypal-create-account' }] + ); +}); diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js index 2bcd7fa..a1647ea 100644 --- a/tests/plus-checkout-create-wait.test.js +++ b/tests/plus-checkout-create-wait.test.js @@ -288,7 +288,7 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' }); }); -test('PayPal no-card binding create waits for hosted success and does not use the old PayPal tail', async () => { +test('PayPal no-card binding create only opens hosted checkout and leaves page steps to later nodes', async () => { const events = []; let currentUrl = 'https://chatgpt.com/'; const executor = api.createPlusCheckoutCreateExecutor({ @@ -315,21 +315,8 @@ test('PayPal no-card binding create waits for hosted success and does not use th ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => { events.push({ type: 'ready', source, tabId, options }); }, - fetch: async (url) => { - events.push({ type: 'fetch', url }); - assert.equal(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', - }, - }), - }; + fetch: async () => { + throw new Error('create node should not fetch address or run PayPal page automation'); }, registerTab: async (source, tabId) => { events.push({ type: 'register', source, tabId }); @@ -345,13 +332,6 @@ test('PayPal no-card binding create waits for hosted success and does not use th currency: 'USD', }; } - if (message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') { - currentUrl = 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted'; - return { clicked: true }; - } - if (message.type === 'PLUS_CHECKOUT_GET_STATE') { - return { hostedVerificationVisible: false }; - } throw new Error(`unexpected message type ${message.type}`); }, setState: async (payload) => { @@ -384,13 +364,105 @@ test('PayPal no-card binding create waits for hosted success and does not use th assert.equal(statePayload.plusCheckoutCurrency, 'USD'); assert.equal(statePayload.plusReturnUrl, ''); assert.equal(events.some((event) => event.type === 'tab-message' && event.message.type === 'FILL_PLUS_BILLING_AND_SUBMIT'), false); + assert.equal(events.some((event) => event.type === 'tab-message' && event.message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP'), false); assert.deepStrictEqual(events.find((event) => event.type === 'complete'), { type: 'complete', step: 'plus-checkout-create', payload: { + plusCheckoutCountry: 'US', + plusCheckoutCurrency: 'USD', + plusCheckoutSource: 'paypal-hosted', + plusCheckoutUrl: 'https://pay.openai.com/c/pay/cs_hosted', + plusReturnUrl: '', + plusHostedCheckoutCompleted: false, + }, + }); +}); + +test('PayPal no-card binding OpenAI checkout node submits hosted page and completes after success transition', async () => { + const events = []; + let currentUrl = 'https://pay.openai.com/c/pay/cs_hosted'; + const executor = api.createPlusCheckoutCreateExecutor({ + addLog: async (message, level = 'info') => { + events.push({ type: 'log', message, level }); + }, + chrome: { + tabs: { + get: async (tabId) => ({ id: tabId, url: currentUrl, 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 }); + assert.equal(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', + }, + }), + }; + }, + getState: async () => ({ + hostedCheckoutPhoneNumber: '(415) 555-1234', + }), + 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 === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') { + currentUrl = 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted'; + return { clicked: true }; + } + if (message.type === 'PLUS_CHECKOUT_GET_STATE') { + return { hostedVerificationVisible: false }; + } + 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.executePayPalHostedOpenAiCheckout({ + plusCheckoutTabId: 55, + plusPaymentMethod: 'paypal-hosted', + hostedCheckoutPhoneNumber: '2125550000', + plusHostedCheckoutOauthDelaySeconds: 0, + }); + + const profileState = events.find((event) => event.type === 'set-state' && event.payload.plusHostedCheckoutGuestProfile)?.payload || {}; + assert.equal(profileState.plusHostedCheckoutGuestProfile.phone, '4155551234'); + assert.equal(profileState.plusHostedCheckoutPhoneDigits, '4155551234'); + assert.equal( + events.find((event) => event.type === 'tab-message' && event.message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP')?.message?.payload?.address?.street, + '1 Main St' + ); + assert.deepStrictEqual(events.find((event) => event.type === 'complete'), { + type: 'complete', + step: 'paypal-hosted-openai-checkout', + payload: { + plusCheckoutUrl: 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted', + plusCheckoutSource: 'paypal-hosted', plusReturnUrl: 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted', plusHostedCheckoutCompleted: true, - plusHostedCheckoutOauthDelaySeconds: 0, }, }); }); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index a08bc38..b7ce637 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -221,6 +221,12 @@ test('step definitions module exposes ordered normal and Plus step metadata', () 'fetch-signup-code', 'fill-profile', 'plus-checkout-create', + 'paypal-hosted-openai-checkout', + 'paypal-hosted-email', + 'paypal-hosted-verification', + 'paypal-hosted-card', + 'paypal-hosted-create-account', + 'paypal-hosted-review', 'oauth-login', 'fetch-login-code', 'confirm-oauth', @@ -230,8 +236,10 @@ test('step definitions module exposes ordered normal and Plus step metadata', () assert.equal(hostedSteps.some((step) => step.key === 'plus-checkout-billing'), false); assert.equal(hostedSteps.some((step) => step.key === 'paypal-approve'), false); assert.equal(hostedSteps.some((step) => step.key === 'plus-checkout-return'), false); - assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - assert.equal(api.getLastStepId({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), 10); + assert.equal(hostedSteps.find((step) => step.key === 'paypal-hosted-openai-checkout')?.title, '无卡直绑提交 OpenAI Checkout'); + assert.equal(hostedSteps.find((step) => step.key === 'paypal-hosted-card')?.title, '无卡直绑填写 PayPal 资料'); + assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + assert.equal(api.getLastStepId({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), 16); assert.deepStrictEqual( goPaySteps.map((step) => step.key), @@ -308,8 +316,8 @@ test('Plus session strategy swaps the OAuth tail for a single SUB2API import nod plusPaymentMethod: 'paypal-hosted', plusAccountAccessStrategy: 'sub2api_codex_session', }, - previousNodeId: 'plus-checkout-create', - expectedStepIds: [1, 2, 3, 4, 5, 6, 7], + previousNodeId: 'paypal-hosted-review', + expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], }, { label: 'gopay', @@ -399,8 +407,8 @@ test('Plus session strategy swaps the OAuth tail for a single CPA import node', plusPaymentMethod: 'paypal-hosted', plusAccountAccessStrategy: 'cpa_codex_session', }, - previousNodeId: 'plus-checkout-create', - expectedStepIds: [1, 2, 3, 4, 5, 6, 7], + previousNodeId: 'paypal-hosted-review', + expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], }, { label: 'gopay',