fix(plus): 增强 checkout 免费试用校验
(cherry picked from commit c52f5058a4b5b68902bccb4d1f099d00c471b7bc)
This commit is contained in:
@@ -6828,6 +6828,11 @@ function isSignupUserAlreadyExistsFailure(error) {
|
|||||||
return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message);
|
return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlusCheckoutNonFreeTrialFailure(error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
return /PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
function isStep9RecoverableAuthError(error) {
|
function isStep9RecoverableAuthError(error) {
|
||||||
const message = String(typeof error === 'string' ? error : error?.message || '');
|
const message = String(typeof error === 'string' ? error : error?.message || '');
|
||||||
return /STEP9_OAUTH_RETRY::/i.test(message)
|
return /STEP9_OAUTH_RETRY::/i.test(message)
|
||||||
@@ -8955,6 +8960,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR
|
|||||||
getStopRequested: () => stopRequested,
|
getStopRequested: () => stopRequested,
|
||||||
hasSavedProgress,
|
hasSavedProgress,
|
||||||
isAddPhoneAuthFailure,
|
isAddPhoneAuthFailure,
|
||||||
|
isPlusCheckoutNonFreeTrialFailure,
|
||||||
isRestartCurrentAttemptError,
|
isRestartCurrentAttemptError,
|
||||||
isSignupUserAlreadyExistsFailure,
|
isSignupUserAlreadyExistsFailure,
|
||||||
isStopError,
|
isStopError,
|
||||||
@@ -9828,6 +9834,7 @@ const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling?
|
|||||||
getAddressSeedForCountry: self.MultiPageAddressSources?.getAddressSeedForCountry,
|
getAddressSeedForCountry: self.MultiPageAddressSources?.getAddressSeedForCountry,
|
||||||
getTabId,
|
getTabId,
|
||||||
isTabAlive,
|
isTabAlive,
|
||||||
|
markCurrentRegistrationAccountUsed,
|
||||||
sendTabMessageUntilStopped,
|
sendTabMessageUntilStopped,
|
||||||
setState,
|
setState,
|
||||||
sleepWithStop,
|
sleepWithStop,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
getState,
|
getState,
|
||||||
hasSavedProgress,
|
hasSavedProgress,
|
||||||
isAddPhoneAuthFailure,
|
isAddPhoneAuthFailure,
|
||||||
|
isPlusCheckoutNonFreeTrialFailure,
|
||||||
isRestartCurrentAttemptError,
|
isRestartCurrentAttemptError,
|
||||||
isSignupUserAlreadyExistsFailure,
|
isSignupUserAlreadyExistsFailure,
|
||||||
isStopError,
|
isStopError,
|
||||||
@@ -502,15 +503,12 @@
|
|||||||
const reason = getErrorMessage(err);
|
const reason = getErrorMessage(err);
|
||||||
roundSummary.failureReasons.push(reason);
|
roundSummary.failureReasons.push(reason);
|
||||||
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
|
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
|
||||||
const blockedByPhoneSupplyExhausted = isPhoneNumberSupplyExhaustedFailure(err);
|
const blockedByPlusNonFreeTrial = typeof isPlusCheckoutNonFreeTrialFailure === 'function'
|
||||||
|
&& isPlusCheckoutNonFreeTrialFailure(err);
|
||||||
const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function'
|
const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function'
|
||||||
&& !keepSameEmailUntilAddPhone
|
&& !keepSameEmailUntilAddPhone
|
||||||
&& isSignupUserAlreadyExistsFailure(err);
|
&& isSignupUserAlreadyExistsFailure(err);
|
||||||
const canRetry = !blockedByAddPhone
|
const canRetry = !blockedByAddPhone && !blockedByPlusNonFreeTrial && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound;
|
||||||
&& !blockedByPhoneSupplyExhausted
|
|
||||||
&& !blockedBySignupUserAlreadyExists
|
|
||||||
&& autoRunSkipFailures
|
|
||||||
&& attemptRun < maxAttemptsForRound;
|
|
||||||
|
|
||||||
await setState({
|
await setState({
|
||||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||||
@@ -551,6 +549,41 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (blockedByPlusNonFreeTrial) {
|
||||||
|
roundSummary.status = 'failed';
|
||||||
|
roundSummary.finalFailureReason = reason;
|
||||||
|
await setState({
|
||||||
|
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||||
|
});
|
||||||
|
await appendRoundRecordIfNeeded('failed', reason);
|
||||||
|
cancelPendingCommands('当前轮因 Plus 免费试用资格不可用已终止。');
|
||||||
|
await broadcastStopToContentScripts();
|
||||||
|
if (!autoRunSkipFailures) {
|
||||||
|
await addLog(
|
||||||
|
`第 ${targetRun}/${totalRuns} 轮检测到 Plus 今日应付金额非 0,自动重试未开启,当前自动运行将停止。`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
stoppedEarly = true;
|
||||||
|
await broadcastAutoRunStatus('stopped', {
|
||||||
|
currentRun: targetRun,
|
||||||
|
totalRuns,
|
||||||
|
attemptRun,
|
||||||
|
sessionId: 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addLog(`第 ${targetRun}/${totalRuns} 轮没有 Plus 免费试用资格,本轮将直接失败并跳过剩余重试。`, 'warn');
|
||||||
|
await addLog(
|
||||||
|
targetRun < totalRuns
|
||||||
|
? `第 ${targetRun}/${totalRuns} 轮因 Plus 今日应付金额非 0 提前结束,自动流程将继续下一轮。`
|
||||||
|
: `第 ${targetRun}/${totalRuns} 轮因 Plus 今日应付金额非 0 提前结束,已无后续轮次,本次自动运行结束。`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
forceFreshTabsNextRun = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (blockedBySignupUserAlreadyExists) {
|
if (blockedBySignupUserAlreadyExists) {
|
||||||
roundSummary.status = 'failed';
|
roundSummary.status = 'failed';
|
||||||
roundSummary.finalFailureReason = reason;
|
roundSummary.finalFailureReason = reason;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
|
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
|
||||||
const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i;
|
const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i;
|
||||||
const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500;
|
const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500;
|
||||||
|
const PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS = 3;
|
||||||
|
const PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS = 20000;
|
||||||
const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz';
|
const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz';
|
||||||
const MEIGUODIZHI_COUNTRY_CONFIG = {
|
const MEIGUODIZHI_COUNTRY_CONFIG = {
|
||||||
AR: { path: '/ar-address', city: 'Buenos Aires', aliases: ['ar', 'argentina', '阿根廷'] },
|
AR: { path: '/ar-address', city: 'Buenos Aires', aliases: ['ar', 'argentina', '阿根廷'] },
|
||||||
@@ -42,10 +44,10 @@
|
|||||||
getAddressSeedForCountry,
|
getAddressSeedForCountry,
|
||||||
getTabId,
|
getTabId,
|
||||||
isTabAlive,
|
isTabAlive,
|
||||||
|
markCurrentRegistrationAccountUsed,
|
||||||
setState,
|
setState,
|
||||||
sleepWithStop,
|
sleepWithStop,
|
||||||
waitForTabCompleteUntilStopped,
|
waitForTabCompleteUntilStopped,
|
||||||
waitForTabUrlMatchUntilStopped,
|
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
function isPlusCheckoutUrl(url = '') {
|
function isPlusCheckoutUrl(url = '') {
|
||||||
@@ -294,6 +296,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForPayPalRedirectAfterSubmit(tabId) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS) {
|
||||||
|
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
||||||
|
if (!tab) {
|
||||||
|
throw new Error('步骤 7:checkout 标签页已关闭,无法继续等待 PayPal 跳转。');
|
||||||
|
}
|
||||||
|
const url = String(tab.url || '');
|
||||||
|
if (/paypal\./i.test(url)) {
|
||||||
|
await waitForTabCompleteUntilStopped(tabId);
|
||||||
|
await sleepWithStop(1000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (url && !isPlusCheckoutUrl(url)) {
|
||||||
|
await addLog(`步骤 7:点击订阅后页面跳转到非 PayPal 地址:${url}`, 'warn');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await sleepWithStop(500);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function inspectCheckoutFrame(tabId, frame) {
|
async function inspectCheckoutFrame(tabId, frame) {
|
||||||
try {
|
try {
|
||||||
const result = await sendFrameMessage(tabId, frame.frameId, {
|
const result = await sendFrameMessage(tabId, frame.frameId, {
|
||||||
@@ -366,6 +390,44 @@
|
|||||||
|| null;
|
|| null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findCheckoutAmountInspection(inspections = []) {
|
||||||
|
return inspections.find((item) => item.result?.checkoutAmountSummary?.hasTodayDue)
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectCheckoutAmountSummary(tabId) {
|
||||||
|
const frames = await getReadyCheckoutFrames(tabId);
|
||||||
|
const inspections = await inspectCheckoutFrames(tabId, frames);
|
||||||
|
const amountInspection = findCheckoutAmountInspection(inspections);
|
||||||
|
return amountInspection?.result?.checkoutAmountSummary || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFreeTrialAmount(tabId, state = {}, options = {}) {
|
||||||
|
const phaseLabel = String(options.phaseLabel || '').trim() || '提交前';
|
||||||
|
const amountSummary = await inspectCheckoutAmountSummary(tabId);
|
||||||
|
if (!amountSummary?.hasTodayDue) {
|
||||||
|
await addLog(`步骤 7:${phaseLabel}未能识别 checkout 的“今日应付金额”,为避免误判将继续执行。`, 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amountSummary.isZero) {
|
||||||
|
await addLog(`步骤 7:${phaseLabel}已确认今日应付金额为 ${amountSummary.rawAmount || '0'},继续执行。`, 'ok');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountLabel = amountSummary.rawAmount || (
|
||||||
|
Number.isFinite(Number(amountSummary.amount)) ? String(amountSummary.amount) : '未知金额'
|
||||||
|
);
|
||||||
|
await addLog(`步骤 7:${phaseLabel}检测到今日应付金额不是 0(${amountLabel}),说明当前账号没有免费试用资格,将跳过 PayPal 提交。`, 'warn');
|
||||||
|
if (typeof markCurrentRegistrationAccountUsed === 'function') {
|
||||||
|
await markCurrentRegistrationAccountUsed(state, {
|
||||||
|
reason: 'plus-checkout-non-free-trial',
|
||||||
|
logPrefix: 'Plus Checkout:当前账号没有免费试用资格',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 7:今日应付金额不是 0(${amountLabel}),当前账号没有免费试用资格,已跳过 PayPal 提交。`);
|
||||||
|
}
|
||||||
|
|
||||||
async function getReadyCheckoutFrames(tabId) {
|
async function getReadyCheckoutFrames(tabId) {
|
||||||
return ensurePlusCheckoutFramesReady(tabId, await getCheckoutFrames(tabId));
|
return ensurePlusCheckoutFramesReady(tabId, await getCheckoutFrames(tabId));
|
||||||
}
|
}
|
||||||
@@ -468,6 +530,9 @@
|
|||||||
logMessage: '步骤 7:Checkout 页面仍在加载,等待账单填写脚本就绪...',
|
logMessage: '步骤 7:Checkout 页面仍在加载,等待账单填写脚本就绪...',
|
||||||
});
|
});
|
||||||
const readyFrames = await getReadyCheckoutFrames(tabId);
|
const readyFrames = await getReadyCheckoutFrames(tabId);
|
||||||
|
await ensureFreeTrialAmount(tabId, state, {
|
||||||
|
phaseLabel: 'Checkout 页面加载后',
|
||||||
|
});
|
||||||
const paymentFrame = await resolvePaymentFrame(tabId, readyFrames);
|
const paymentFrame = await resolvePaymentFrame(tabId, readyFrames);
|
||||||
if (paymentFrame.frameId === null) {
|
if (paymentFrame.frameId === null) {
|
||||||
const frameSummary = buildFrameSummary(paymentFrame.inspections);
|
const frameSummary = buildFrameSummary(paymentFrame.inspections);
|
||||||
@@ -535,8 +600,9 @@
|
|||||||
addressSeed,
|
addressSeed,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (suggestionResult?.error) {
|
const suggestionError = suggestionResult?.error || '';
|
||||||
throw new Error(suggestionResult.error);
|
if (suggestionError) {
|
||||||
|
await addLog(`步骤 7:Google 地址推荐不可用,将改用本地地址字段兜底:${suggestionError}`, 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
const structuredResult = await sendFrameMessage(tabId, billingFrame.frameId, {
|
const structuredResult = await sendFrameMessage(tabId, billingFrame.frameId, {
|
||||||
@@ -544,6 +610,7 @@
|
|||||||
source: 'background',
|
source: 'background',
|
||||||
payload: {
|
payload: {
|
||||||
addressSeed,
|
addressSeed,
|
||||||
|
overwriteStructuredAddress: Boolean(suggestionError),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (structuredResult?.error) {
|
if (structuredResult?.error) {
|
||||||
@@ -552,7 +619,7 @@
|
|||||||
|
|
||||||
result = {
|
result = {
|
||||||
...structuredResult,
|
...structuredResult,
|
||||||
selectedAddressText: suggestionResult?.selectedAddressText || '',
|
selectedAddressText: suggestionError ? '' : (suggestionResult?.selectedAddressText || ''),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
result = await sendFrameMessage(tabId, billingFrame.frameId, {
|
result = await sendFrameMessage(tabId, billingFrame.frameId, {
|
||||||
@@ -569,7 +636,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await addLog('步骤 7:账单地址已填写完成,正在定位订阅按钮...', 'info');
|
await setState({
|
||||||
|
plusCheckoutTabId: tabId,
|
||||||
|
plusBillingCountryText: result?.countryText || '',
|
||||||
|
plusBillingAddress: result?.structuredAddress || null,
|
||||||
|
});
|
||||||
|
await ensureFreeTrialAmount(tabId, state, {
|
||||||
|
phaseLabel: '提交订阅前',
|
||||||
|
});
|
||||||
|
|
||||||
|
let redirectedToPayPal = false;
|
||||||
|
let lastSubmitError = '';
|
||||||
|
for (let attempt = 1; attempt <= PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
await addLog(
|
||||||
|
attempt === 1
|
||||||
|
? '步骤 7:账单地址已填写完成,等待 3 秒让 checkout 完成校验...'
|
||||||
|
: `步骤 7:准备第 ${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS} 次重新提交账单地址...`,
|
||||||
|
attempt === 1 ? 'info' : 'warn'
|
||||||
|
);
|
||||||
|
await sleepWithStop(3000);
|
||||||
|
await addLog('步骤 7:正在定位订阅按钮...', 'info');
|
||||||
const subscribeFrame = await waitForSubscribeFrame(tabId, [
|
const subscribeFrame = await waitForSubscribeFrame(tabId, [
|
||||||
{ frameId: 0, url: '' },
|
{ frameId: 0, url: '' },
|
||||||
{ frameId: paymentFrame.frameId, url: paymentFrame.frameUrl || '' },
|
{ frameId: paymentFrame.frameId, url: paymentFrame.frameUrl || '' },
|
||||||
@@ -578,22 +664,28 @@
|
|||||||
const subscribeResult = await sendFrameMessage(tabId, subscribeFrame.frameId, {
|
const subscribeResult = await sendFrameMessage(tabId, subscribeFrame.frameId, {
|
||||||
type: 'PLUS_CHECKOUT_CLICK_SUBSCRIBE',
|
type: 'PLUS_CHECKOUT_CLICK_SUBSCRIBE',
|
||||||
source: 'background',
|
source: 'background',
|
||||||
payload: {},
|
payload: {
|
||||||
|
beforeClickDelayMs: attempt === 1 ? 700 : 1200,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (subscribeResult?.error) {
|
if (subscribeResult?.error) {
|
||||||
throw new Error(subscribeResult.error);
|
lastSubmitError = subscribeResult.error;
|
||||||
|
await addLog(`步骤 7:点击订阅失败(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS}):${lastSubmitError}`, 'warn');
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setState({
|
await addLog(`步骤 7:账单地址已提交,正在等待跳转到 PayPal(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS})...`, 'info');
|
||||||
plusCheckoutTabId: tabId,
|
redirectedToPayPal = await waitForPayPalRedirectAfterSubmit(tabId);
|
||||||
plusBillingCountryText: result?.countryText || '',
|
if (redirectedToPayPal) {
|
||||||
plusBillingAddress: result?.structuredAddress || null,
|
break;
|
||||||
});
|
}
|
||||||
|
lastSubmitError = `提交后 ${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 PayPal`;
|
||||||
|
await addLog(`步骤 7:${lastSubmitError},将重试提交。`, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
await addLog('步骤 7:账单地址已提交,正在等待跳转到 PayPal...', 'info');
|
if (!redirectedToPayPal) {
|
||||||
await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url));
|
throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 PayPal。${lastSubmitError}`);
|
||||||
await waitForTabCompleteUntilStopped(tabId);
|
}
|
||||||
await sleepWithStop(1000);
|
|
||||||
|
|
||||||
await completeStepFromBackground(7, {
|
await completeStepFromBackground(7, {
|
||||||
plusBillingCountryText: result?.countryText || '',
|
plusBillingCountryText: result?.countryText || '',
|
||||||
|
|||||||
+223
-30
@@ -83,7 +83,7 @@ async function handlePlusCheckoutCommand(message) {
|
|||||||
case 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS':
|
case 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS':
|
||||||
return ensurePlusStructuredBillingAddress(message.payload || {});
|
return ensurePlusStructuredBillingAddress(message.payload || {});
|
||||||
case 'PLUS_CHECKOUT_CLICK_SUBSCRIBE':
|
case 'PLUS_CHECKOUT_CLICK_SUBSCRIBE':
|
||||||
return clickPlusSubscribe();
|
return clickPlusSubscribe(message.payload || {});
|
||||||
case 'PLUS_CHECKOUT_GET_STATE':
|
case 'PLUS_CHECKOUT_GET_STATE':
|
||||||
return inspectPlusCheckoutState();
|
return inspectPlusCheckoutState();
|
||||||
default:
|
default:
|
||||||
@@ -94,12 +94,17 @@ async function handlePlusCheckoutCommand(message) {
|
|||||||
async function waitUntil(predicate, options = {}) {
|
async function waitUntil(predicate, options = {}) {
|
||||||
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
|
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
|
||||||
const label = String(options.label || '条件').trim() || '条件';
|
const label = String(options.label || '条件').trim() || '条件';
|
||||||
|
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
|
||||||
|
const startedAt = Date.now();
|
||||||
while (true) {
|
while (true) {
|
||||||
throwIfStopped();
|
throwIfStopped();
|
||||||
const value = await predicate();
|
const value = await predicate();
|
||||||
if (value) {
|
if (value) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
|
||||||
|
throw new Error(`${label}等待超时`);
|
||||||
|
}
|
||||||
await sleep(intervalMs);
|
await sleep(intervalMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,24 +131,97 @@ function normalizeText(text = '') {
|
|||||||
return String(text || '').replace(/\s+/g, ' ').trim();
|
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePlusPaymentMethod(value = '') {
|
function parseLocalizedAmount(rawValue = '') {
|
||||||
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
|
const raw = normalizeText(rawValue);
|
||||||
|
const match = raw.match(/(?:[$€£¥]\s*)?([+-]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})|[+-]?\d+(?:[.,]\d{1,2})?)(?:\s*[$€£¥])?/);
|
||||||
|
if (!match) return null;
|
||||||
|
let numericText = String(match[1] || '').trim();
|
||||||
|
const lastComma = numericText.lastIndexOf(',');
|
||||||
|
const lastDot = numericText.lastIndexOf('.');
|
||||||
|
if (lastComma > -1 && lastDot > -1) {
|
||||||
|
const decimalSeparator = lastComma > lastDot ? ',' : '.';
|
||||||
|
const thousandsSeparator = decimalSeparator === ',' ? '.' : ',';
|
||||||
|
numericText = numericText
|
||||||
|
.replace(new RegExp(`\\${thousandsSeparator}`, 'g'), '')
|
||||||
|
.replace(decimalSeparator, '.');
|
||||||
|
} else if (lastComma > -1) {
|
||||||
|
numericText = numericText.replace(',', '.');
|
||||||
|
}
|
||||||
|
const amount = Number(numericText.replace(/[^\d.+-]/g, ''));
|
||||||
|
return Number.isFinite(amount)
|
||||||
|
? {
|
||||||
|
amount,
|
||||||
|
raw: match[0],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlusCheckoutConfig(payload = {}) {
|
function getTextAfterTodayDueLabel(text = '') {
|
||||||
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
|
const normalized = normalizeText(text);
|
||||||
const config = PLUS_CHECKOUT_CONFIGS[paymentMethod] || PLUS_CHECKOUT_CONFIGS.paypal;
|
const match = normalized.match(/(?:今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today)/i);
|
||||||
|
if (!match) return '';
|
||||||
|
return normalized.slice((match.index || 0) + match[0].length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckoutAmountSummary() {
|
||||||
|
const elements = getVisibleControls('div, span, p, strong, b');
|
||||||
|
const labelPattern = /今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today/i;
|
||||||
|
const amountPattern = /[$€£¥]\s*[+-]?\d|[+-]?\d+(?:[.,]\d{1,2})?\s*[$€£¥]/;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
const text = normalizeText(element.innerText || element.textContent || '');
|
||||||
|
if (!labelPattern.test(text)) continue;
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
const afterLabelText = getTextAfterTodayDueLabel(text);
|
||||||
|
if (afterLabelText) candidates.push(afterLabelText);
|
||||||
|
|
||||||
|
const parent = element.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
for (const child of Array.from(parent.children || [])) {
|
||||||
|
if (child === element) continue;
|
||||||
|
const childText = normalizeText(child.innerText || child.textContent || '');
|
||||||
|
if (amountPattern.test(childText)) {
|
||||||
|
candidates.push(childText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parentAfterLabelText = getTextAfterTodayDueLabel(parent.innerText || parent.textContent || '');
|
||||||
|
if (parentAfterLabelText) candidates.push(parentAfterLabelText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grandparent = parent?.parentElement;
|
||||||
|
if (grandparent) {
|
||||||
|
const grandparentAfterLabelText = getTextAfterTodayDueLabel(grandparent.innerText || grandparent.textContent || '');
|
||||||
|
if (grandparentAfterLabelText) candidates.push(grandparentAfterLabelText);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const parsed = parseLocalizedAmount(candidate);
|
||||||
|
if (!parsed) continue;
|
||||||
return {
|
return {
|
||||||
paymentMethod,
|
hasTodayDue: true,
|
||||||
paymentLabel: config.paymentLabel,
|
amount: parsed.amount,
|
||||||
checkoutUrlPrefix: config.checkoutUrlPrefix,
|
isZero: Math.abs(parsed.amount) < 0.005,
|
||||||
checkoutPayload: {
|
rawAmount: parsed.raw,
|
||||||
...PLUS_CHECKOUT_BASE_PAYLOAD,
|
labelText: text.slice(0, 160),
|
||||||
billing_details: {
|
};
|
||||||
country: config.billing_details.country,
|
}
|
||||||
currency: config.billing_details.currency,
|
|
||||||
},
|
return {
|
||||||
},
|
hasTodayDue: true,
|
||||||
|
amount: null,
|
||||||
|
isZero: false,
|
||||||
|
rawAmount: '',
|
||||||
|
labelText: text.slice(0, 160),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasTodayDue: false,
|
||||||
|
amount: null,
|
||||||
|
isZero: false,
|
||||||
|
rawAmount: '',
|
||||||
|
labelText: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +230,14 @@ function getActionText(el) {
|
|||||||
el?.textContent,
|
el?.textContent,
|
||||||
el?.value,
|
el?.value,
|
||||||
el?.getAttribute?.('aria-label'),
|
el?.getAttribute?.('aria-label'),
|
||||||
|
el?.getAttribute?.('aria-labelledby'),
|
||||||
el?.getAttribute?.('title'),
|
el?.getAttribute?.('title'),
|
||||||
el?.getAttribute?.('placeholder'),
|
el?.getAttribute?.('placeholder'),
|
||||||
el?.getAttribute?.('name'),
|
el?.getAttribute?.('name'),
|
||||||
|
el?.getAttribute?.('autocomplete'),
|
||||||
|
el?.getAttribute?.('data-elements-stable-field-name'),
|
||||||
|
el?.getAttribute?.('data-field'),
|
||||||
|
el?.getAttribute?.('data-field-name'),
|
||||||
el?.id,
|
el?.id,
|
||||||
].filter(Boolean).join(' '));
|
].filter(Boolean).join(' '));
|
||||||
}
|
}
|
||||||
@@ -670,6 +753,7 @@ async function clickAddressSuggestion(seed = {}) {
|
|||||||
}, {
|
}, {
|
||||||
label: '地址推荐列表',
|
label: '地址推荐列表',
|
||||||
intervalMs: 250,
|
intervalMs: 250,
|
||||||
|
timeoutMs: 6000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const suggestionIndex = Math.max(0, Math.min(
|
const suggestionIndex = Math.max(0, Math.min(
|
||||||
@@ -820,7 +904,7 @@ function findRegionDropdown() {
|
|||||||
if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false;
|
if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false;
|
||||||
const text = getFieldText(control);
|
const text = getFieldText(control);
|
||||||
if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false;
|
if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false;
|
||||||
return /state|province|county/i.test(text)
|
return /state|province|county|prefecture|administrative|administrative[_-]?area/i.test(text)
|
||||||
|| /(?:^|\s)region(?:\s|$)/i.test(text)
|
|| /(?:^|\s)region(?:\s|$)/i.test(text)
|
||||||
|| /\u5dde|\u7701|\u8f96\u533a|\u90fd\u9053\u5e9c\u53bf/.test(text);
|
|| /\u5dde|\u7701|\u8f96\u533a|\u90fd\u9053\u5e9c\u53bf/.test(text);
|
||||||
}) || null;
|
}) || null;
|
||||||
@@ -956,24 +1040,24 @@ async function selectCountryDropdown(countryDropdown, value) {
|
|||||||
|
|
||||||
function getStructuredAddressFields() {
|
function getStructuredAddressFields() {
|
||||||
const address1 = findInputByFieldText([
|
const address1 = findInputByFieldText([
|
||||||
/address\s*(?:line)?\s*1|street/i,
|
/address\s*(?:line)?\s*1|address[_-]?line[_-]?1|address\[(?:address_)?line1\]|line\s*1|street|street[_-]?address/i,
|
||||||
/地址\s*1|街道|详细地址/i,
|
/地址\s*1|街道|详细地址|住所/i,
|
||||||
]);
|
]);
|
||||||
const address2 = findInputByFieldText([
|
const address2 = findInputByFieldText([
|
||||||
/address\s*(?:line)?\s*2|apt|suite|unit/i,
|
/address\s*(?:line)?\s*2|address[_-]?line[_-]?2|address\[(?:address_)?line2\]|line\s*2|apt|suite|unit/i,
|
||||||
/地址\s*2|公寓|单元|门牌/i,
|
/地址\s*2|公寓|单元|门牌/i,
|
||||||
]);
|
]);
|
||||||
const city = findInputByFieldText([
|
const city = findInputByFieldText([
|
||||||
/city|town|suburb/i,
|
/city|town|suburb|locality|address[_-]?level[_-]?2|address\[city\]/i,
|
||||||
/城市|市区/i,
|
/城市|市区|区市町村|市区町村|市町村/i,
|
||||||
]);
|
]);
|
||||||
const region = findInputByFieldText([
|
const region = findInputByFieldText([
|
||||||
/state|province|region|county/i,
|
/state|province|region|county|prefecture|administrative|administrative[_-]?area|address[_-]?level[_-]?1|address\[state\]/i,
|
||||||
/省|州|地区/i,
|
/省|州|地区|辖区|都道府县|都道府県/i,
|
||||||
]);
|
]);
|
||||||
const postalCode = findInputByFieldText([
|
const postalCode = findInputByFieldText([
|
||||||
/postal|zip|postcode/i,
|
/postal|zip|postcode|postal[_-]?code|zip[_-]?code|address\[postal_code\]/i,
|
||||||
/邮编|邮政/i,
|
/邮编|邮政|郵便番号/i,
|
||||||
]);
|
]);
|
||||||
return { address1, address2, city, region, postalCode };
|
return { address1, address2, city, region, postalCode };
|
||||||
}
|
}
|
||||||
@@ -1012,6 +1096,7 @@ async function ensureStructuredAddress(seed, options = {}) {
|
|||||||
}, {
|
}, {
|
||||||
label: '结构化账单地址字段',
|
label: '结构化账单地址字段',
|
||||||
intervalMs: 250,
|
intervalMs: 250,
|
||||||
|
timeoutMs: 6000,
|
||||||
});
|
});
|
||||||
|
|
||||||
fillIfEmpty(fields.address1, fallback.address1, { overwrite });
|
fillIfEmpty(fields.address1, fallback.address1, { overwrite });
|
||||||
@@ -1038,12 +1123,111 @@ async function ensureStructuredAddress(seed, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findSubscribeButton() {
|
function findSubscribeButton() {
|
||||||
|
const submitButtons = getVisibleControls('button[type="submit"], input[type="submit"]');
|
||||||
|
const exactSubmit = submitButtons.find((button) => (
|
||||||
|
isEnabledControl(button)
|
||||||
|
&& /订阅|subscribe|购买\s*ChatGPT\s*Plus|start\s*subscription|place\s*order/i.test(getCombinedSearchText(button))
|
||||||
|
));
|
||||||
|
if (exactSubmit) {
|
||||||
|
return exactSubmit;
|
||||||
|
}
|
||||||
|
|
||||||
return findClickableByText([
|
return findClickableByText([
|
||||||
/订阅|继续|确认|支付/i,
|
/订阅|继续|确认|支付/i,
|
||||||
/subscribe|continue|confirm|pay|start\s*subscription|place\s*order/i,
|
/subscribe|continue|confirm|pay|start\s*subscription|place\s*order/i,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBusySubscribeButton(button) {
|
||||||
|
if (!button) return true;
|
||||||
|
const text = getActionText(button);
|
||||||
|
return button.disabled
|
||||||
|
|| button.getAttribute?.('aria-disabled') === 'true'
|
||||||
|
|| button.getAttribute?.('aria-busy') === 'true'
|
||||||
|
|| button.closest?.('[aria-busy="true"], [data-loading="true"], [data-state="loading"]')
|
||||||
|
|| /loading|processing|submitting|请稍候|处理中|加载中/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssociatedForm(button) {
|
||||||
|
if (!button) return null;
|
||||||
|
if (button.form) return button.form;
|
||||||
|
const formId = String(button.getAttribute?.('form') || '').trim();
|
||||||
|
if (formId) {
|
||||||
|
return document.getElementById(formId) || null;
|
||||||
|
}
|
||||||
|
return button.closest?.('form') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function humanLikeClick(el) {
|
||||||
|
throwIfStopped();
|
||||||
|
if (!el) {
|
||||||
|
throw new Error('无法点击空元素。');
|
||||||
|
}
|
||||||
|
|
||||||
|
el.scrollIntoView?.({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||||
|
await sleep(300);
|
||||||
|
if (typeof el.focus === 'function') {
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const clientX = Math.floor(rect.left + rect.width / 2);
|
||||||
|
const clientY = Math.floor(rect.top + rect.height / 2);
|
||||||
|
const eventInit = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
view: window,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
button: 0,
|
||||||
|
buttons: 1,
|
||||||
|
};
|
||||||
|
const pointerCtor = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent;
|
||||||
|
const events = [
|
||||||
|
['pointerover', pointerCtor],
|
||||||
|
['pointerenter', pointerCtor],
|
||||||
|
['mouseover', MouseEvent],
|
||||||
|
['mouseenter', MouseEvent],
|
||||||
|
['pointermove', pointerCtor],
|
||||||
|
['mousemove', MouseEvent],
|
||||||
|
['pointerdown', pointerCtor],
|
||||||
|
['mousedown', MouseEvent],
|
||||||
|
['pointerup', pointerCtor],
|
||||||
|
['mouseup', MouseEvent],
|
||||||
|
['click', MouseEvent],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [type, EventCtor] of events) {
|
||||||
|
throwIfStopped();
|
||||||
|
el.dispatchEvent(new EventCtor(type, eventInit));
|
||||||
|
await sleep(type === 'mousedown' || type === 'pointerdown' ? 120 : 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof el.click === 'function') {
|
||||||
|
await sleep(120);
|
||||||
|
el.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = String(el.getAttribute?.('type') || el.type || '').trim().toLowerCase();
|
||||||
|
const form = getAssociatedForm(el);
|
||||||
|
if (
|
||||||
|
form
|
||||||
|
&& typeof form.requestSubmit === 'function'
|
||||||
|
&& (
|
||||||
|
(String(el.tagName || '').toUpperCase() === 'BUTTON' && (!type || type === 'submit'))
|
||||||
|
|| (String(el.tagName || '').toUpperCase() === 'INPUT' && type === 'submit')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await sleep(250);
|
||||||
|
form.requestSubmit(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[MultiPage:plus-checkout] 已执行拟人工点击', summarizeElementForDebug(el));
|
||||||
|
log(`已拟人工点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
|
||||||
|
}
|
||||||
|
|
||||||
async function fillPlusBillingAndSubmit(payload = {}) {
|
async function fillPlusBillingAndSubmit(payload = {}) {
|
||||||
await waitForDocumentComplete();
|
await waitForDocumentComplete();
|
||||||
await selectPayPalPaymentMethod();
|
await selectPayPalPaymentMethod();
|
||||||
@@ -1117,23 +1301,31 @@ async function selectPlusAddressSuggestion(payload = {}) {
|
|||||||
|
|
||||||
async function ensurePlusStructuredBillingAddress(payload = {}) {
|
async function ensurePlusStructuredBillingAddress(payload = {}) {
|
||||||
await waitForDocumentComplete();
|
await waitForDocumentComplete();
|
||||||
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {});
|
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}, {
|
||||||
|
overwrite: Boolean(payload.overwriteStructuredAddress),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
countryText: readCountryText(),
|
countryText: readCountryText(),
|
||||||
structuredAddress,
|
structuredAddress,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickPlusSubscribe() {
|
async function clickPlusSubscribe(payload = {}) {
|
||||||
|
if (payload.ensurePayPalActive && !isPayPalPaymentMethodActive()) {
|
||||||
|
await selectPayPalPaymentMethod();
|
||||||
|
}
|
||||||
|
|
||||||
const subscribeButton = await waitUntil(() => {
|
const subscribeButton = await waitUntil(() => {
|
||||||
const button = findSubscribeButton();
|
const button = findSubscribeButton();
|
||||||
return button && isEnabledControl(button) ? button : null;
|
return button && isEnabledControl(button) && !isBusySubscribeButton(button) ? button : null;
|
||||||
}, {
|
}, {
|
||||||
label: '订阅按钮',
|
label: '订阅按钮',
|
||||||
intervalMs: 250,
|
intervalMs: 250,
|
||||||
|
timeoutMs: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
simulateClick(subscribeButton);
|
await sleep(Math.max(0, Math.floor(Number(payload.beforeClickDelayMs) || 0)));
|
||||||
|
await humanLikeClick(subscribeButton);
|
||||||
return {
|
return {
|
||||||
clicked: true,
|
clicked: true,
|
||||||
};
|
};
|
||||||
@@ -1151,6 +1343,7 @@ function inspectPlusCheckoutState() {
|
|||||||
cardFieldsVisible: hasCreditCardFields(),
|
cardFieldsVisible: hasCreditCardFields(),
|
||||||
billingFieldsVisible: hasBillingAddressFields(),
|
billingFieldsVisible: hasBillingAddressFields(),
|
||||||
hasSubscribeButton: Boolean(findSubscribeButton()),
|
hasSubscribeButton: Boolean(findSubscribeButton()),
|
||||||
|
checkoutAmountSummary: getCheckoutAmountSummary(),
|
||||||
addressFieldValues: {
|
addressFieldValues: {
|
||||||
address1: structuredAddress.address1?.value || '',
|
address1: structuredAddress.address1?.value || '',
|
||||||
city: structuredAddress.city?.value || '',
|
city: structuredAddress.city?.value || '',
|
||||||
|
|||||||
@@ -180,6 +180,75 @@ return { findAddressSearchInput, isNonAddressSearchInput };
|
|||||||
assert.equal(await api.findAddressSearchInput(), addressInput);
|
assert.equal(await api.findAddressSearchInput(), addressInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getCheckoutAmountSummary reads non-zero today due amount', () => {
|
||||||
|
const label = createElement({ tagName: 'DIV', text: '今日应付金额' });
|
||||||
|
const amount = createElement({ tagName: 'DIV', text: '€19.33' });
|
||||||
|
const row = createElement({ tagName: 'DIV', text: '今日应付金额 €19.33' });
|
||||||
|
label.parentElement = row;
|
||||||
|
amount.parentElement = row;
|
||||||
|
row.children = [label, amount];
|
||||||
|
row.parentElement = null;
|
||||||
|
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('isVisibleElement'),
|
||||||
|
extractFunction('normalizeText'),
|
||||||
|
extractFunction('parseLocalizedAmount'),
|
||||||
|
extractFunction('getTextAfterTodayDueLabel'),
|
||||||
|
extractFunction('getVisibleControls'),
|
||||||
|
extractFunction('getCheckoutAmountSummary'),
|
||||||
|
'return { getCheckoutAmountSummary };',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const api = new Function('window', 'document', bundle)(
|
||||||
|
{
|
||||||
|
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
querySelectorAll: () => [label, amount, row],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = api.getCheckoutAmountSummary();
|
||||||
|
assert.equal(summary.hasTodayDue, true);
|
||||||
|
assert.equal(summary.isZero, false);
|
||||||
|
assert.equal(summary.amount, 19.33);
|
||||||
|
assert.equal(summary.rawAmount, '€19.33');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCheckoutAmountSummary accepts zero today due amount', () => {
|
||||||
|
const label = createElement({ tagName: 'DIV', text: '今日应付金额' });
|
||||||
|
const amount = createElement({ tagName: 'DIV', text: '€0.00' });
|
||||||
|
const row = createElement({ tagName: 'DIV', text: '今日应付金额 €0.00' });
|
||||||
|
label.parentElement = row;
|
||||||
|
amount.parentElement = row;
|
||||||
|
row.children = [label, amount];
|
||||||
|
row.parentElement = null;
|
||||||
|
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('isVisibleElement'),
|
||||||
|
extractFunction('normalizeText'),
|
||||||
|
extractFunction('parseLocalizedAmount'),
|
||||||
|
extractFunction('getTextAfterTodayDueLabel'),
|
||||||
|
extractFunction('getVisibleControls'),
|
||||||
|
extractFunction('getCheckoutAmountSummary'),
|
||||||
|
'return { getCheckoutAmountSummary };',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const api = new Function('window', 'document', bundle)(
|
||||||
|
{
|
||||||
|
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
querySelectorAll: () => [label, amount, row],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = api.getCheckoutAmountSummary();
|
||||||
|
assert.equal(summary.hasTodayDue, true);
|
||||||
|
assert.equal(summary.isZero, true);
|
||||||
|
assert.equal(summary.amount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
|
test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
|
||||||
const bundle = [
|
const bundle = [
|
||||||
extractFunction('isVisibleElement'),
|
extractFunction('isVisibleElement'),
|
||||||
@@ -234,6 +303,164 @@ return { isPayPalPaymentMethodActive };
|
|||||||
assert.equal(api.isPayPalPaymentMethodActive(), true);
|
assert.equal(api.isPayPalPaymentMethodActive(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getStructuredAddressFields recognizes Stripe localized address field names', () => {
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('isVisibleElement'),
|
||||||
|
extractFunction('normalizeText'),
|
||||||
|
extractFunction('getActionText'),
|
||||||
|
extractFunction('getFieldText'),
|
||||||
|
extractFunction('getVisibleControls'),
|
||||||
|
extractFunction('getVisibleTextInputs'),
|
||||||
|
extractFunction('findInputByFieldText'),
|
||||||
|
extractFunction('getStructuredAddressFields'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const address1 = createInput({ name: 'addressLine1', placeholder: '住所' });
|
||||||
|
const city = createInput({ name: 'locality', placeholder: '市区町村' });
|
||||||
|
const region = createInput({ name: 'administrativeArea', placeholder: '辖区' });
|
||||||
|
const postalCode = createInput({ name: 'postalCode', placeholder: '郵便番号' });
|
||||||
|
const inputs = [address1, city, region, postalCode];
|
||||||
|
const documentMock = {
|
||||||
|
querySelectorAll: (selector) => {
|
||||||
|
if (selector === 'input, textarea') return inputs;
|
||||||
|
if (String(selector || '').includes('label[for=')) return [];
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const windowMock = {
|
||||||
|
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||||
|
};
|
||||||
|
const cssMock = {
|
||||||
|
escape: (value) => String(value),
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = new Function('window', 'document', 'CSS', `
|
||||||
|
${bundle}
|
||||||
|
return { getStructuredAddressFields };
|
||||||
|
`)(windowMock, documentMock, cssMock);
|
||||||
|
|
||||||
|
assert.deepEqual(api.getStructuredAddressFields(), {
|
||||||
|
address1,
|
||||||
|
address2: null,
|
||||||
|
city,
|
||||||
|
region,
|
||||||
|
postalCode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findSubscribeButton prefers the submit subscription button', () => {
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('isVisibleElement'),
|
||||||
|
extractFunction('normalizeText'),
|
||||||
|
extractFunction('getActionText'),
|
||||||
|
extractFunction('getSearchText'),
|
||||||
|
extractFunction('getFieldText'),
|
||||||
|
extractFunction('getCombinedSearchText'),
|
||||||
|
extractFunction('getVisibleControls'),
|
||||||
|
extractFunction('findClickableByText'),
|
||||||
|
extractFunction('isEnabledControl'),
|
||||||
|
extractFunction('findSubscribeButton'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const submitButton = createElement({
|
||||||
|
tagName: 'BUTTON',
|
||||||
|
text: '订阅',
|
||||||
|
attrs: {
|
||||||
|
'aria-label': '订阅',
|
||||||
|
type: 'submit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
submitButton.type = 'submit';
|
||||||
|
|
||||||
|
const genericButton = createElement({
|
||||||
|
tagName: 'BUTTON',
|
||||||
|
text: '继续',
|
||||||
|
attrs: {
|
||||||
|
type: 'button',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
genericButton.type = 'button';
|
||||||
|
|
||||||
|
const documentMock = {
|
||||||
|
body: {},
|
||||||
|
documentElement: {},
|
||||||
|
querySelectorAll: (selector) => {
|
||||||
|
if (selector === 'button[type="submit"], input[type="submit"]') return [submitButton];
|
||||||
|
if (selector === 'button, a, [role="button"], input[type="button"], input[type="submit"], [tabindex]') {
|
||||||
|
return [genericButton, submitButton];
|
||||||
|
}
|
||||||
|
if (String(selector || '').includes('label[for=')) return [];
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const windowMock = {
|
||||||
|
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||||
|
};
|
||||||
|
const cssMock = {
|
||||||
|
escape: (value) => String(value),
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = new Function('window', 'document', 'CSS', `
|
||||||
|
${bundle}
|
||||||
|
return { findSubscribeButton };
|
||||||
|
`)(windowMock, documentMock, cssMock);
|
||||||
|
|
||||||
|
assert.equal(api.findSubscribeButton(), submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('humanLikeClick submits a detached submit button through its form attribute', async () => {
|
||||||
|
const bundle = [
|
||||||
|
'function throwIfStopped() {}',
|
||||||
|
'function sleep() { return Promise.resolve(); }',
|
||||||
|
'function summarizeElementForDebug() { return {}; }',
|
||||||
|
'function log() {}',
|
||||||
|
extractFunction('normalizeText'),
|
||||||
|
extractFunction('getActionText'),
|
||||||
|
extractFunction('getAssociatedForm'),
|
||||||
|
extractFunction('humanLikeClick'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
let submittedWith = null;
|
||||||
|
const form = {
|
||||||
|
requestSubmit(button) {
|
||||||
|
submittedWith = button;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const button = createElement({
|
||||||
|
tagName: 'BUTTON',
|
||||||
|
text: '订阅',
|
||||||
|
attrs: {
|
||||||
|
form: '_r_l_',
|
||||||
|
type: 'submit',
|
||||||
|
'aria-label': '订阅',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
button.type = 'submit';
|
||||||
|
button.scrollIntoView = () => {};
|
||||||
|
button.focus = () => {};
|
||||||
|
button.dispatchEvent = () => true;
|
||||||
|
button.click = () => {};
|
||||||
|
|
||||||
|
const documentMock = {
|
||||||
|
getElementById: (id) => (id === '_r_l_' ? form : null),
|
||||||
|
};
|
||||||
|
const windowMock = {};
|
||||||
|
class FakeMouseEvent {
|
||||||
|
constructor(type, init = {}) {
|
||||||
|
this.type = type;
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = new Function('window', 'document', 'MouseEvent', 'PointerEvent', 'console', `
|
||||||
|
${bundle}
|
||||||
|
return { humanLikeClick };
|
||||||
|
`)(windowMock, documentMock, FakeMouseEvent, undefined, { log() {} });
|
||||||
|
|
||||||
|
await api.humanLikeClick(button);
|
||||||
|
assert.equal(submittedWith, button);
|
||||||
|
});
|
||||||
|
|
||||||
test('selectRegionDropdown opens the state dropdown and clicks the matching option', async () => {
|
test('selectRegionDropdown opens the state dropdown and clicks the matching option', async () => {
|
||||||
const bundle = [
|
const bundle = [
|
||||||
'function throwIfStopped() {}',
|
'function throwIfStopped() {}',
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function createExecutorHarness({
|
|||||||
readyByFrame = {},
|
readyByFrame = {},
|
||||||
fetchImpl = null,
|
fetchImpl = null,
|
||||||
getAddressSeedForCountry = () => createAddressSeed(),
|
getAddressSeedForCountry = () => createAddressSeed(),
|
||||||
|
markCurrentRegistrationAccountUsed = async () => {},
|
||||||
}) {
|
}) {
|
||||||
const api = loadPlusCheckoutBillingModule();
|
const api = loadPlusCheckoutBillingModule();
|
||||||
const events = {
|
const events = {
|
||||||
@@ -100,6 +101,9 @@ function createExecutorHarness({
|
|||||||
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
|
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
|
||||||
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
|
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
|
||||||
}
|
}
|
||||||
|
if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') {
|
||||||
|
checkoutTab.url = 'https://www.paypal.com/checkoutnow';
|
||||||
|
}
|
||||||
return createSuccessfulBillingResult();
|
return createSuccessfulBillingResult();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -121,6 +125,7 @@ function createExecutorHarness({
|
|||||||
getAddressSeedForCountry,
|
getAddressSeedForCountry,
|
||||||
getTabId: async () => null,
|
getTabId: async () => null,
|
||||||
isTabAlive: async () => false,
|
isTabAlive: async () => false,
|
||||||
|
markCurrentRegistrationAccountUsed,
|
||||||
setState: async (updates) => events.states.push(updates),
|
setState: async (updates) => events.states.push(updates),
|
||||||
sleepWithStop: async () => {},
|
sleepWithStop: async () => {},
|
||||||
waitForTabCompleteUntilStopped: async () => checkoutTab,
|
waitForTabCompleteUntilStopped: async () => checkoutTab,
|
||||||
@@ -134,6 +139,42 @@ function createExecutorHarness({
|
|||||||
return { checkoutTab, events, executor };
|
return { checkoutTab, events, executor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('Plus checkout billing stops before PayPal when today due amount is non-zero', async () => {
|
||||||
|
const markCalls = [];
|
||||||
|
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,
|
||||||
|
checkoutAmountSummary: {
|
||||||
|
hasTodayDue: true,
|
||||||
|
amount: 19.33,
|
||||||
|
isZero: false,
|
||||||
|
rawAmount: '€19.33',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
markCurrentRegistrationAccountUsed: async (state, options) => {
|
||||||
|
markCalls.push({ state, options });
|
||||||
|
return { updated: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => executor.executePlusCheckoutBilling({ email: 'paid@example.com' }),
|
||||||
|
/PLUS_CHECKOUT_NON_FREE_TRIAL::/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE'), false);
|
||||||
|
assert.equal(events.completed.length, 0);
|
||||||
|
assert.equal(markCalls.length, 1);
|
||||||
|
assert.equal(markCalls[0].state.email, 'paid@example.com');
|
||||||
|
assert.equal(events.logs.some((entry) => /今日应付金额不是 0/.test(entry.message)), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('Plus checkout billing uses the current checkout tab when step 6 did not register one', async () => {
|
test('Plus checkout billing uses the current checkout tab when step 6 did not register one', async () => {
|
||||||
const { checkoutTab, events, executor } = createExecutorHarness({
|
const { checkoutTab, events, executor } = createExecutorHarness({
|
||||||
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
|
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
|
||||||
|
|||||||
Reference in New Issue
Block a user