diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index e7018f9..c2f2991 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -35,12 +35,12 @@ 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 授权复核页' }, + [PAYPAL_HOSTED_STEP_OPENAI_CHECKOUT]: { step: 6, label: '创建 PayPal 无卡直绑 Checkout' }, + [PAYPAL_HOSTED_STEP_EMAIL]: { step: 7, label: '无卡直绑 PayPal 邮箱页' }, + [PAYPAL_HOSTED_STEP_VERIFICATION]: { step: 8, label: '无卡直绑 PayPal 验证码页' }, + [PAYPAL_HOSTED_STEP_CARD]: { step: 9, label: '无卡直绑 PayPal 资料页' }, + [PAYPAL_HOSTED_STEP_CREATE_ACCOUNT]: { step: 10, label: '无卡直绑 PayPal 创建确认页' }, + [PAYPAL_HOSTED_STEP_REVIEW]: { step: 11, label: '无卡直绑 PayPal 授权复核页' }, }); function createPlusCheckoutCreateExecutor(deps = {}) { @@ -686,7 +686,7 @@ throw new Error('步骤 6:PayPal 无卡直绑未返回可用的订阅链接。'); } - await addLog('步骤 6:PayPal 无卡直绑链接已创建,正在打开 hosted checkout 页面...', 'ok'); + await addLog('步骤 6:PayPal 无卡直绑链接已创建,正在打开并提交 OpenAI Checkout 页面...', 'ok'); await chrome.tabs.update(tabId, { url: targetCheckoutUrl, active: true }); await waitForTabCompleteUntilStopped(tabId); @@ -697,26 +697,37 @@ 500 ); const landedUrl = String(landedTab?.url || targetCheckoutUrl || '').trim(); + let completedUrl = landedUrl; - const isAlreadySuccessful = isHostedCheckoutSuccessUrl(landedUrl); + if (isHostedOpenAiCheckoutUrl(completedUrl)) { + const { profile, config } = await ensureHostedGuestProfile(state); + await addLog(`步骤 6:正在提交 OpenAI Checkout,等待跳转到 PayPal 邮箱页(电话使用本地号码 ${profile.phone})。`, 'info'); + completedUrl = String(await runHostedOpenAiCheckout(tabId, profile, config) || await getHostedCurrentUrl(tabId) || '').trim(); + } + + if (isPayPalUrl(completedUrl)) { + await waitForTabCompleteUntilStopped(tabId).catch(() => {}); + } + + const isAlreadySuccessful = isHostedCheckoutSuccessUrl(completedUrl); await setState({ plusCheckoutTabId: tabId, - plusCheckoutUrl: landedUrl, + plusCheckoutUrl: completedUrl, plusCheckoutCountry: result.country || 'US', plusCheckoutCurrency: result.currency || 'USD', plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED, - plusReturnUrl: isAlreadySuccessful ? landedUrl : '', + plusReturnUrl: isAlreadySuccessful ? completedUrl : '', plusHostedCheckoutCompleted: isAlreadySuccessful, }); - await addLog(`步骤 6:PayPal 无卡直绑页面已就绪(${result.country || 'US'} ${result.currency || 'USD'}),准备进入页面级直绑节点。`, 'info'); + await addLog(`步骤 6:PayPal 无卡直绑已提交 OpenAI Checkout(${result.country || 'US'} ${result.currency || 'USD'}),准备进入 PayPal 邮箱页。`, 'info'); await completeNodeFromBackground('plus-checkout-create', { plusCheckoutCountry: result.country || 'US', plusCheckoutCurrency: result.currency || 'USD', plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED, - plusCheckoutUrl: landedUrl, - plusReturnUrl: isAlreadySuccessful ? landedUrl : '', + plusCheckoutUrl: completedUrl, + plusReturnUrl: isAlreadySuccessful ? completedUrl : '', plusHostedCheckoutCompleted: isAlreadySuccessful, }); } diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 6255b8d..6a4d813 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -5,8 +5,8 @@ const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'content/plus-checkout.js']; const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i; const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500; - const PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS = 3; - const PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS = 20000; + const PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS = 5; + const PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS = 10000; const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; @@ -1883,7 +1883,7 @@ await addLog( attempt === 1 ? '步骤 7:账单地址已填写完成,等待 3 秒让 checkout 完成校验...' - : `步骤 7:准备第 ${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS} 次重新提交账单地址...`, + : `步骤 7:准备第 ${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS} 次重新检测订阅按钮...`, attempt === 1 ? 'info' : 'warn' ); await sleepWithStop(3000); @@ -1907,17 +1907,27 @@ continue; } - await addLog(`步骤 7:账单地址已提交,正在等待跳转到 ${paymentConfig.label}(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS})...`, 'info'); + const subscribeClicked = subscribeResult?.clicked !== false; + const subscribeButtonText = String(subscribeResult?.subscribeButtonText || '').trim(); + const subscribeButtonStatus = String(subscribeResult?.subscribeButtonStatus || '').trim(); + if (subscribeClicked) { + await addLog(`步骤 7:已点击订阅按钮,正在等待跳转到 ${paymentConfig.label}(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS})...`, 'info'); + } else { + const buttonStateLabel = subscribeButtonText || subscribeButtonStatus || 'unknown'; + await addLog(`步骤 7:订阅按钮当前为「${buttonStateLabel}」,本轮未点击,正在等待页面是否跳转到 ${paymentConfig.label}(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS})...`, 'warn'); + } redirectedToPayment = await waitForPaymentRedirectAfterSubmit(tabId, paymentMethod); if (redirectedToPayment) { break; } - lastSubmitError = `提交后 ${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 ${paymentConfig.label}`; - await addLog(`步骤 7:${lastSubmitError},将重试提交。`, 'warn'); + lastSubmitError = subscribeClicked + ? `点击订阅后 ${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 ${paymentConfig.label}` + : `订阅按钮当前为「${subscribeButtonText || subscribeButtonStatus || 'unknown'}」,${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 ${paymentConfig.label}`; + await addLog(`步骤 7:${lastSubmitError},将重新检测订阅按钮。`, 'warn'); } if (!redirectedToPayment) { - throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 ${paymentConfig.label}。${lastSubmitError}`); + throw new Error(`步骤 7:多次检测订阅按钮后仍未跳转到 ${paymentConfig.label}。${lastSubmitError}`); } await completeNodeFromBackground('plus-checkout-billing', { diff --git a/content/paypal-flow.js b/content/paypal-flow.js index a1d844b..82f9db8 100644 --- a/content/paypal-flow.js +++ b/content/paypal-flow.js @@ -292,6 +292,9 @@ function findHostedCreateAccountButton() { } function isHostedCreateAccountPage() { + if (isHostedLoginPage()) { + return false; + } if (document.getElementById('cardNumber') || document.getElementById('billingLine1')) { return false; } @@ -427,6 +430,31 @@ async function clickHostedSubmitButton(options = {}) { }; } +async function clickHostedEmailNextButton() { + const button = await waitUntil(() => { + const candidate = findEmailNextButton(); + return candidate && isVisibleElement(candidate) && isEnabledControl(candidate) ? candidate : null; + }, { + intervalMs: 500, + timeoutMs: 15000, + timeoutMessage: 'PayPal hosted checkout 未找到邮箱页“下一页”按钮。', + }); + const buttonText = getActionText(button); + await performPayPalOperationWithDelay({ + stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_LOGIN), + kind: 'click', + label: 'hosted-paypal-email-next', + }, async () => { + simulateClick(button); + }); + await sleep(1000); + return { + clicked: true, + buttonText, + verificationRequired: hasHostedVerificationInputs(), + }; +} + function normalizeHostedPhoneDigits(value = '') { return String(value || '').replace(/\D/g, ''); } @@ -530,10 +558,7 @@ async function submitHostedLogin(payload = {}) { throw new Error('PayPal hosted checkout 未找到邮箱输入框。'); } refillPayPalEmailInput(emailInput, email); - const clickResult = await clickHostedSubmitButton({ - stage: PAYPAL_HOSTED_STAGE_LOGIN, - label: 'hosted-paypal-email-submit', - }); + const clickResult = await clickHostedEmailNextButton(); return { stage: PAYPAL_HOSTED_STAGE_LOGIN, submitted: true, diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 7ca4353..a502a4f 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -1524,6 +1524,42 @@ function isBusySubscribeButton(button) { || /loading|processing|submitting|请稍候|处理中|加载中/i.test(text); } +const SUBSCRIBE_READY_TEXT_PATTERN = /\u8ba2\u9605|\u7ee7\u7eed|\u786e\u8ba4|\u652f\u4ed8|subscribe|continue|confirm|pay|\u8d2d\u4e70\s*ChatGPT\s*Plus|start\s*subscription|place\s*order/i; +const SUBSCRIBE_PROCESSING_TEXT_PATTERN = /\u6b63\u5728\u5904\u7406|\u5904\u7406\u4e2d|\u8bf7\u7a0d\u5019|\u52a0\u8f7d\u4e2d|loading|processing|submitting/i; + +function getSubscribeButtonState(button) { + if (!button) { + return { + found: false, + enabled: false, + busy: false, + ready: false, + status: 'missing', + text: '', + }; + } + const text = normalizeText([ + button.innerText, + button.textContent, + button.value, + button.getAttribute?.('aria-label'), + ].filter(Boolean).join(' ')) || getActionText(button); + const searchText = getCombinedSearchText(button); + const combinedText = normalizeText(`${text} ${searchText}`); + const enabled = isEnabledControl(button); + const busy = Boolean(isBusySubscribeButton(button) || SUBSCRIBE_PROCESSING_TEXT_PATTERN.test(combinedText)); + const readyText = SUBSCRIBE_READY_TEXT_PATTERN.test(combinedText); + const ready = Boolean(enabled && readyText && !busy); + return { + found: true, + enabled, + busy, + ready, + status: busy ? 'processing' : (!enabled ? 'disabled' : (readyText ? 'ready' : 'unknown')), + text: text || searchText, + }; +} + function getAssociatedForm(button) { if (!button) return null; if (button.form) return button.form; @@ -1710,12 +1746,23 @@ async function clickPlusSubscribe(payload = {}) { const subscribeButton = await waitUntil(() => { const button = findSubscribeButton(); - return button && isEnabledControl(button) && !isBusySubscribeButton(button) ? button : null; + return button || null; }, { label: '订阅按钮', intervalMs: 250, timeoutMs: 10000, }); + const buttonState = getSubscribeButtonState(subscribeButton); + if (!buttonState.ready) { + log(`订阅按钮当前状态 [${buttonState.status}] "${buttonState.text.slice(0, 40)}",本轮不点击`); + return { + clicked: false, + subscribeButtonBusy: buttonState.busy, + subscribeButtonEnabled: buttonState.enabled, + subscribeButtonStatus: buttonState.status, + subscribeButtonText: buttonState.text, + }; + } await sleep(Math.max(0, Math.floor(Number(payload.beforeClickDelayMs) || 0))); await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'submit', label: 'click-subscribe' }, async () => { @@ -1723,6 +1770,8 @@ async function clickPlusSubscribe(payload = {}) { }); return { clicked: true, + subscribeButtonStatus: 'clicked', + subscribeButtonText: buttonState.text, }; } @@ -1739,6 +1788,7 @@ async function readChatGptSessionAccessToken() { async function inspectPlusCheckoutState(options = {}) { const structuredAddress = getStructuredAddressFields(); + const subscribeButtonState = getSubscribeButtonState(findSubscribeButton()); const state = { url: location.href, readyState: document.readyState, @@ -1750,7 +1800,11 @@ async function inspectPlusCheckoutState(options = {}) { paymentTextPreview: getPaymentTextPreview(), cardFieldsVisible: hasCreditCardFields(), billingFieldsVisible: hasBillingAddressFields(), - hasSubscribeButton: Boolean(findSubscribeButton()), + hasSubscribeButton: subscribeButtonState.found, + subscribeButtonBusy: subscribeButtonState.busy, + subscribeButtonEnabled: subscribeButtonState.enabled, + subscribeButtonStatus: subscribeButtonState.status, + subscribeButtonText: subscribeButtonState.text, hostedOpenAiPage: isPayPalHostedOpenAiCheckoutPage(), hostedVerificationVisible: hasHostedOpenAiVerificationDialog(), hostedPayPalButtonFound: Boolean(findHostedPayPalButton()), diff --git a/data/step-definitions.js b/data/step-definitions.js index 909d31b..8a06a77 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -35,12 +35,11 @@ ]; 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' }, + { id: 7, order: 70, key: 'paypal-hosted-email', title: '无卡直绑填写 PayPal 邮箱', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-email' }, + { id: 8, order: 80, key: 'paypal-hosted-verification', title: '无卡直绑填写 PayPal 验证码', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-verification' }, + { id: 9, order: 90, key: 'paypal-hosted-card', title: '无卡直绑填写 PayPal 资料', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-card' }, + { id: 10, order: 100, key: 'paypal-hosted-create-account', title: '无卡直绑确认创建 PayPal', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-create-account' }, + { id: 11, order: 110, key: 'paypal-hosted-review', title: '无卡直绑完成 PayPal 授权', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-review' }, ]; const PLUS_GOPAY_PREFIX_STEP_DEFINITIONS = [ @@ -240,23 +239,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, 13, 130, SIGNUP_METHOD_EMAIL); + const PLUS_PAYPAL_HOSTED_CHECKOUT_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 12, 120, SIGNUP_METHOD_EMAIL); const PLUS_PAYPAL_HOSTED_CHECKOUT_SUB2API_SESSION_STEP_DEFINITIONS = createHostedCheckoutSteps( PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, - 13, - 130, + 12, + 120, 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, - 13, - 130, + 12, + 120, 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, 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_PAYPAL_HOSTED_CHECKOUT_PHONE_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 12, 120, SIGNUP_METHOD_PHONE); + const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 12, 120, 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/tests/paypal-flow-content.test.js b/tests/paypal-flow-content.test.js index f6cc5f3..0313208 100644 --- a/tests/paypal-flow-content.test.js +++ b/tests/paypal-flow-content.test.js @@ -408,6 +408,11 @@ function createHostedPayPalHarness(options = {}) { text: 'Agree & Create Account', attrs: { 'data-testid': 'createAccountButton' }, }); + const nextButton = createDomElement({ + tagName: 'BUTTON', + id: 'btnNext', + text: '下一页', + }); function setElements(nextElements) { elements = nextElements; @@ -450,6 +455,15 @@ function createHostedPayPalHarness(options = {}) { setElements([createAccountButton]); } + function showPayEmail() { + location.href = 'https://www.paypal.com/pay?token=demo'; + location.host = 'www.paypal.com'; + location.pathname = '/pay'; + body.innerText = '请输入您的电子邮箱地址。 下一页 或 创建账户'; + body.textContent = body.innerText; + setElements([emailInput, nextButton, createAccountButton]); + } + const context = { console: { log() {}, warn() {}, error() {}, info() {} }, location, @@ -541,6 +555,7 @@ function createHostedPayPalHarness(options = {}) { return { events, send, + showPayEmail, showCreateAccount, showGuestCheckout, }; @@ -610,6 +625,40 @@ test('PayPal hosted guest checkout blocks submit when rendered phone differs fro assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'hostedSubmit'), false); }); +test('PayPal hosted /pay email page fills email and clicks Next instead of Create Account', async () => { + const harness = createHostedPayPalHarness(); + harness.showPayEmail(); + + const state = await harness.send({ + type: 'PAYPAL_HOSTED_GET_STATE', + source: 'test', + payload: {}, + }); + assert.equal(state.ok, true); + assert.equal(state.hostedStage, 'pay_login'); + assert.equal(state.hasHostedEmailInput, true); + + const result = await harness.send({ + type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP', + source: 'test', + payload: { + expectedStage: 'pay_login', + email: 'guest@example.com', + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.stage, 'pay_login'); + assert.equal(result.submitted, true); + assert.equal(harness.events.some((event) => event.type === 'fill' && event.id === 'email' && event.value === 'guest@example.com'), true); + assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'btnNext'), true); + assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'createAccountButton'), false); + assert.deepEqual( + JSON.parse(JSON.stringify(harness.events.filter((event) => event.type === 'operation').map((event) => event.metadata))), + [{ stepKey: 'paypal-hosted-email', kind: 'click', label: 'hosted-paypal-email-next' }] + ); +}); + test('PayPal hosted create account page is detected and handled as its own step', async () => { const harness = createHostedPayPalHarness(); harness.showCreateAccount(); diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js index ae97416..f46868b 100644 --- a/tests/plus-checkout-address-input.test.js +++ b/tests/plus-checkout-address-input.test.js @@ -566,6 +566,49 @@ return { findSubscribeButton }; assert.equal(api.findSubscribeButton(), submitButton); }); +test('getSubscribeButtonState does not treat processing subscribe text as clickable', () => { + const bundle = [ + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getSearchText'), + extractFunction('getFieldText'), + extractFunction('getCombinedSearchText'), + extractFunction('isEnabledControl'), + extractFunction('isBusySubscribeButton'), + 'const SUBSCRIBE_READY_TEXT_PATTERN = /\\u8ba2\\u9605|\\u7ee7\\u7eed|\\u786e\\u8ba4|\\u652f\\u4ed8|subscribe|continue|confirm|pay|\\u8d2d\\u4e70\\s*ChatGPT\\s*Plus|start\\s*subscription|place\\s*order/i;', + 'const SUBSCRIBE_PROCESSING_TEXT_PATTERN = /\\u6b63\\u5728\\u5904\\u7406|\\u5904\\u7406\\u4e2d|\\u8bf7\\u7a0d\\u5019|\\u52a0\\u8f7d\\u4e2d|loading|processing|submitting/i;', + extractFunction('getSubscribeButtonState'), + ].join('\n'); + + const processingButton = createElement({ + tagName: 'BUTTON', + text: '订阅 正在处理', + attrs: { type: 'submit' }, + }); + const readyButton = createElement({ + tagName: 'BUTTON', + text: '订阅', + attrs: { type: 'submit' }, + }); + + const api = new Function(` +${bundle} +return { getSubscribeButtonState }; +`)(); + + assert.deepEqual( + { + ready: api.getSubscribeButtonState(processingButton).ready, + status: api.getSubscribeButtonState(processingButton).status, + }, + { + ready: false, + status: 'processing', + } + ); + assert.equal(api.getSubscribeButtonState(readyButton).ready, true); +}); + test('humanLikeClick submits a detached submit button through its form attribute', async () => { const bundle = [ 'function throwIfStopped() {}', diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 2726930..790f33d 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -84,6 +84,7 @@ function createExecutorHarness({ getState = null, queryTabsInAutomationWindow = null, markCurrentRegistrationAccountUsed = async () => {}, + onClickSubscribe = null, probeIpProxyExit = null, onSetState = null, sleepWithStop = null, @@ -137,7 +138,14 @@ function createExecutorHarness({ return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] }; } if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') { - checkoutTab.url = submitRedirectUrl; + if (typeof onClickSubscribe === 'function') { + const clickResult = await onClickSubscribe({ checkoutTab, events, frameId, message, tabId }); + if (clickResult !== undefined) { + return clickResult; + } + } else { + checkoutTab.url = submitRedirectUrl; + } } return createSuccessfulBillingResult(); }, @@ -242,6 +250,57 @@ test('Plus checkout billing uses the current checkout tab when step 6 did not re assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true); }); +test('Plus checkout billing waits on processing subscribe text before clicking a ready subscribe button again', async () => { + const originalNow = Date.now; + let now = 0; + let clickCalls = 0; + Date.now = () => now; + try { + const { events, executor } = createExecutorHarness({ + frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }], + stateByFrame: { + 0: { + hasPayPal: true, + paypalCandidates: [{ tag: 'button', text: 'PayPal' }], + billingFieldsVisible: true, + hasSubscribeButton: true, + }, + }, + onClickSubscribe: async ({ checkoutTab }) => { + clickCalls += 1; + if (clickCalls === 1) { + return { + clicked: false, + subscribeButtonStatus: 'processing', + subscribeButtonText: '订阅正在处理', + }; + } + checkoutTab.url = 'https://www.paypal.com/checkoutnow'; + return { + clicked: true, + subscribeButtonStatus: 'clicked', + subscribeButtonText: '订阅', + }; + }, + sleepWithStop: async (ms) => { + events.sleeps.push(ms); + now += ms; + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const subscribeMessages = events.messages.filter((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE'); + assert.equal(subscribeMessages.length, 2); + assert.equal(subscribeMessages.some((entry) => entry.message.payload.allowBusySubscribeButton !== undefined), false); + assert.equal(events.sleeps.filter((ms) => ms === 500).length >= 20, true); + assert.equal(events.logs.some((entry) => /本轮未点击/.test(entry.message)), true); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); + } finally { + Date.now = originalNow; + } +}); + test('Plus checkout billing searches checkout tabs inside the locked automation window', async () => { const queries = []; const { checkoutTab, executor } = createExecutorHarness({ diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js index a1647ea..ea7460c 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 only opens hosted checkout and leaves page steps to later nodes', async () => { +test('PayPal no-card binding create opens and submits hosted OpenAI checkout before completing', async () => { const events = []; let currentUrl = 'https://chatgpt.com/'; const executor = api.createPlusCheckoutCreateExecutor({ @@ -315,8 +315,27 @@ test('PayPal no-card binding create only opens hosted checkout and leaves page s ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => { events.push({ type: 'ready', source, tabId, options }); }, - fetch: async () => { - throw new Error('create node should not fetch address or run PayPal page automation'); + 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 () => { + events.push({ type: 'get-state' }); + return { + hostedCheckoutPhoneNumber: '4155551234', + }; }, registerTab: async (source, tabId) => { events.push({ type: 'register', source, tabId }); @@ -332,6 +351,10 @@ test('PayPal no-card binding create only opens hosted checkout and leaves page s currency: 'USD', }; } + if (message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') { + currentUrl = 'https://www.paypal.com/pay?token=BA-hosted'; + return { clicked: true }; + } throw new Error(`unexpected message type ${message.type}`); }, setState: async (payload) => { @@ -358,13 +381,17 @@ test('PayPal no-card binding create only opens hosted checkout and leaves page s events.find((event) => event.type === 'tab-update')?.payload?.url, 'https://pay.openai.com/c/pay/cs_hosted' ); - const statePayload = events.find((event) => event.type === 'set-state')?.payload || {}; + const statePayload = events.filter((event) => event.type === 'set-state').at(-1)?.payload || {}; assert.equal(statePayload.plusCheckoutSource, 'paypal-hosted'); assert.equal(statePayload.plusCheckoutCountry, 'US'); 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.equal(events.some((event) => event.type === 'tab-message' && event.message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP'), true); + 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: 'plus-checkout-create', @@ -372,7 +399,7 @@ test('PayPal no-card binding create only opens hosted checkout and leaves page s plusCheckoutCountry: 'US', plusCheckoutCurrency: 'USD', plusCheckoutSource: 'paypal-hosted', - plusCheckoutUrl: 'https://pay.openai.com/c/pay/cs_hosted', + plusCheckoutUrl: 'https://www.paypal.com/pay?token=BA-hosted', plusReturnUrl: '', plusHostedCheckoutCompleted: false, }, diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index b7ce637..2118121 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -221,7 +221,6 @@ 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', @@ -236,10 +235,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.equal(hostedSteps.find((step) => step.key === 'paypal-hosted-openai-checkout')?.title, '无卡直绑提交 OpenAI Checkout'); + assert.equal(hostedSteps.some((step) => step.key === 'paypal-hosted-openai-checkout'), false); 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(api.getStepIds({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); + assert.equal(api.getLastStepId({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), 15); assert.deepStrictEqual( goPaySteps.map((step) => step.key), @@ -317,7 +316,7 @@ test('Plus session strategy swaps the OAuth tail for a single SUB2API import nod plusAccountAccessStrategy: 'sub2api_codex_session', }, previousNodeId: 'paypal-hosted-review', - expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], }, { label: 'gopay', @@ -408,7 +407,7 @@ test('Plus session strategy swaps the OAuth tail for a single CPA import node', plusAccountAccessStrategy: 'cpa_codex_session', }, previousNodeId: 'paypal-hosted-review', - expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], }, { label: 'gopay',