feat: 更新 Plus Checkout 逻辑,优化子框架的就绪状态处理,增加地址输入和 PayPal 付款方法的检测功能
This commit is contained in:
@@ -80,3 +80,15 @@ return { detectScriptSource };
|
||||
'mail-163'
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldReportReadyForFrame suppresses noisy plus checkout child frame ready logs', () => {
|
||||
const bundle = [extractFunction('shouldReportReadyForFrame')].join('\n');
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { shouldReportReadyForFrame };
|
||||
`)();
|
||||
|
||||
assert.equal(api.shouldReportReadyForFrame('plus-checkout', true), false);
|
||||
assert.equal(api.shouldReportReadyForFrame('plus-checkout', false), true);
|
||||
assert.equal(api.shouldReportReadyForFrame('paypal-flow', true), true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/plus-checkout.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const plainStart = source.indexOf(`function ${name}(`);
|
||||
const asyncStart = source.indexOf(`async function ${name}(`);
|
||||
const start = asyncStart >= 0
|
||||
? asyncStart
|
||||
: plainStart;
|
||||
if (start < 0) {
|
||||
throw new Error(`missing function ${name}`);
|
||||
}
|
||||
|
||||
let parenDepth = 0;
|
||||
let signatureEnded = false;
|
||||
let braceStart = -1;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const ch = source[index];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (braceStart < 0) {
|
||||
throw new Error(`missing body for function ${name}`);
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < source.length; end += 1) {
|
||||
const ch = source[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createInput({ id = '', name = '', placeholder = '', containerText = '' }) {
|
||||
const attrs = {
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
type: 'text',
|
||||
};
|
||||
const container = {
|
||||
textContent: containerText,
|
||||
};
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'text',
|
||||
value: '',
|
||||
textContent: '',
|
||||
getAttribute: (key) => attrs[key] || '',
|
||||
closest: (selector) => {
|
||||
if (selector === 'label') return null;
|
||||
if (String(selector || '').includes('[data-testid]')) return container;
|
||||
return null;
|
||||
},
|
||||
getBoundingClientRect: () => ({ width: 240, height: 40 }),
|
||||
};
|
||||
}
|
||||
|
||||
function createElement({ tagName = 'BUTTON', text = '', attrs = {}, className = '' }) {
|
||||
return {
|
||||
tagName,
|
||||
textContent: text,
|
||||
value: '',
|
||||
className,
|
||||
dataset: {},
|
||||
id: attrs.id || '',
|
||||
checked: false,
|
||||
getAttribute: (key) => attrs[key] || '',
|
||||
closest: () => null,
|
||||
getBoundingClientRect: () => ({ width: 180, height: 64 }),
|
||||
};
|
||||
}
|
||||
|
||||
test('findAddressSearchInput skips the name field when its container says billing address', async () => {
|
||||
const bundle = [
|
||||
'function throwIfStopped() {}',
|
||||
'function sleep() { return Promise.resolve(); }',
|
||||
extractFunction('waitUntil'),
|
||||
extractFunction('isVisibleElement'),
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('getActionText'),
|
||||
extractFunction('getFieldText'),
|
||||
extractFunction('getVisibleControls'),
|
||||
extractFunction('getVisibleTextInputs'),
|
||||
extractFunction('findInputByFieldText'),
|
||||
extractFunction('getDirectFieldHintText'),
|
||||
extractFunction('isNonAddressSearchInput'),
|
||||
extractFunction('isLikelyAddressSearchInput'),
|
||||
extractFunction('findAddressSearchInput'),
|
||||
].join('\n');
|
||||
|
||||
const nameInput = createInput({
|
||||
name: 'name',
|
||||
placeholder: 'Name',
|
||||
containerText: 'Billing address',
|
||||
});
|
||||
const addressInput = createInput({
|
||||
name: 'addressLine1',
|
||||
placeholder: 'Address',
|
||||
containerText: 'Billing address',
|
||||
});
|
||||
const inputs = [nameInput, addressInput];
|
||||
const documentMock = {
|
||||
readyState: 'complete',
|
||||
querySelectorAll: (selector) => {
|
||||
if (selector === 'input, textarea') return inputs;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const windowMock = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
};
|
||||
const cssMock = {
|
||||
escape: (value) => String(value),
|
||||
};
|
||||
|
||||
const api = new Function('window', 'document', 'CSS', `
|
||||
${bundle}
|
||||
return { findAddressSearchInput, isNonAddressSearchInput };
|
||||
`)(windowMock, documentMock, cssMock);
|
||||
|
||||
assert.equal(api.isNonAddressSearchInput(nameInput), true);
|
||||
assert.equal(await api.findAddressSearchInput(), addressInput);
|
||||
});
|
||||
|
||||
test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
|
||||
const bundle = [
|
||||
extractFunction('isVisibleElement'),
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('getActionText'),
|
||||
extractFunction('getSearchText'),
|
||||
extractFunction('getFieldText'),
|
||||
extractFunction('getCombinedSearchText'),
|
||||
extractFunction('getVisibleControls'),
|
||||
extractFunction('getVisibleTextInputs'),
|
||||
extractFunction('isDocumentLevelContainer'),
|
||||
extractFunction('getPayPalSearchCandidates'),
|
||||
extractFunction('hasCreditCardFields'),
|
||||
extractFunction('hasSelectedPayPalControl'),
|
||||
extractFunction('isPayPalPaymentMethodActive'),
|
||||
].join('\n');
|
||||
|
||||
const paypalButton = createElement({
|
||||
text: 'PayPal',
|
||||
attrs: {
|
||||
id: 'paypal-tab',
|
||||
role: 'tab',
|
||||
'aria-selected': '',
|
||||
},
|
||||
});
|
||||
const elements = [paypalButton];
|
||||
const documentMock = {
|
||||
documentElement: {},
|
||||
body: {},
|
||||
querySelectorAll: (selector) => {
|
||||
if (selector === 'input, textarea') return [];
|
||||
if (String(selector || '').includes('label[for=')) return [];
|
||||
return elements;
|
||||
},
|
||||
};
|
||||
const windowMock = {
|
||||
innerWidth: 1200,
|
||||
innerHeight: 900,
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
};
|
||||
const cssMock = {
|
||||
escape: (value) => String(value),
|
||||
};
|
||||
|
||||
const api = new Function('window', 'document', 'CSS', `
|
||||
${bundle}
|
||||
return { isPayPalPaymentMethodActive };
|
||||
`)(windowMock, documentMock, cssMock);
|
||||
|
||||
assert.equal(api.isPayPalPaymentMethodActive(), false);
|
||||
paypalButton.getAttribute = (key) => (key === 'aria-selected' ? 'true' : (paypalButton.id && key === 'id' ? paypalButton.id : ''));
|
||||
assert.equal(api.isPayPalPaymentMethodActive(), true);
|
||||
});
|
||||
@@ -67,12 +67,16 @@ function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) {
|
||||
sendMessage: async (tabId, message, options = {}) => {
|
||||
const frameId = Number.isInteger(options.frameId) ? options.frameId : 0;
|
||||
events.messages.push({ tabId, message, frameId });
|
||||
const hasConfiguredState = Object.prototype.hasOwnProperty.call(stateByFrame, frameId);
|
||||
if (message.type === 'PING') {
|
||||
if (readyByFrame[frameId] === false) {
|
||||
throw new Error('No receiving end');
|
||||
}
|
||||
return { ok: true, source: 'plus-checkout' };
|
||||
}
|
||||
if (readyByFrame[frameId] === false && !hasConfiguredState) {
|
||||
throw new Error('No receiving end');
|
||||
}
|
||||
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
|
||||
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
|
||||
}
|
||||
@@ -159,6 +163,34 @@ test('Plus checkout billing sends the billing command to the iframe that contain
|
||||
assert.equal(events.completed[0].step, 7);
|
||||
});
|
||||
|
||||
test('Plus checkout billing still inspects a frame when ping readiness is stale', async () => {
|
||||
const { events, executor } = createExecutorHarness({
|
||||
frames: [
|
||||
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
|
||||
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
|
||||
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
|
||||
],
|
||||
stateByFrame: {
|
||||
0: {
|
||||
hasPayPal: true,
|
||||
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
|
||||
hasSubscribeButton: true,
|
||||
},
|
||||
7: { hasPayPal: false, paypalCandidates: [] },
|
||||
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
|
||||
},
|
||||
readyByFrame: {
|
||||
0: false,
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({});
|
||||
|
||||
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
|
||||
assert.equal(selectMessage.frameId, 0);
|
||||
assert.equal(events.completed[0].step, 7);
|
||||
});
|
||||
|
||||
test('Plus checkout billing uses the autocomplete iframe for address suggestions when Stripe splits it out', async () => {
|
||||
const { events, executor } = createExecutorHarness({
|
||||
frames: [
|
||||
|
||||
Reference in New Issue
Block a user