fix(plus): 增强 checkout 免费试用校验

(cherry picked from commit c52f5058a4b5b68902bccb4d1f099d00c471b7bc)
This commit is contained in:
朴圣佑
2026-04-26 22:22:44 +08:00
committed by QLHazyCoder
parent 75e259ae5c
commit aa4e52b9a7
6 changed files with 652 additions and 59 deletions
+227
View File
@@ -180,6 +180,75 @@ return { findAddressSearchInput, isNonAddressSearchInput };
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', () => {
const bundle = [
extractFunction('isVisibleElement'),
@@ -234,6 +303,164 @@ return { isPayPalPaymentMethodActive };
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 () => {
const bundle = [
'function throwIfStopped() {}',
@@ -53,6 +53,7 @@ function createExecutorHarness({
readyByFrame = {},
fetchImpl = null,
getAddressSeedForCountry = () => createAddressSeed(),
markCurrentRegistrationAccountUsed = async () => {},
}) {
const api = loadPlusCheckoutBillingModule();
const events = {
@@ -100,6 +101,9 @@ function createExecutorHarness({
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
}
if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') {
checkoutTab.url = 'https://www.paypal.com/checkoutnow';
}
return createSuccessfulBillingResult();
},
},
@@ -121,6 +125,7 @@ function createExecutorHarness({
getAddressSeedForCountry,
getTabId: async () => null,
isTabAlive: async () => false,
markCurrentRegistrationAccountUsed,
setState: async (updates) => events.states.push(updates),
sleepWithStop: async () => {},
waitForTabCompleteUntilStopped: async () => checkoutTab,
@@ -134,6 +139,42 @@ function createExecutorHarness({
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 () => {
const { checkoutTab, events, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],