feat(gopay): support GoPay Plus checkout flow

This commit is contained in:
朴圣佑
2026-05-01 02:02:33 +08:00
committed by QLHazyCoder
parent 2eb913e00b
commit d851cc4d36
24 changed files with 3798 additions and 162 deletions
+5
View File
@@ -9,6 +9,7 @@ test('address sources normalize supported countries and return local seeds', ()
assert.equal(api.normalizeCountryCode('Deutschland'), 'DE');
assert.equal(api.normalizeCountryCode('澳大利亚'), 'AU');
assert.equal(api.normalizeCountryCode('印尼'), 'ID');
assert.equal(api.normalizeCountryCode('日本'), 'JP');
assert.equal(api.normalizeCountryCode('unknown'), '');
@@ -22,6 +23,10 @@ test('address sources normalize supported countries and return local seeds', ()
assert.equal(fallbackSeed.countryCode, 'AU');
assert.equal(fallbackSeed.fallback.region, 'New South Wales');
const idSeed = api.getAddressSeedForCountry('Indonesia');
assert.equal(idSeed.countryCode, 'ID');
assert.equal(idSeed.fallback.region, 'DKI Jakarta');
const jpSeed = api.getAddressSeedForCountry('日本');
assert.equal(jpSeed.countryCode, 'JP');
assert.equal(jpSeed.fallback.region, 'Tokyo');
+8
View File
@@ -12,5 +12,13 @@ test('background imports step registry and shared step definitions', () => {
assert.match(source, /background\/steps\/create-plus-checkout\.js/);
assert.match(source, /background\/steps\/fill-plus-checkout\.js/);
assert.match(source, /background\/steps\/paypal-approve\.js/);
assert.match(source, /background\/steps\/gopay-approve\.js/);
assert.match(source, /background\/steps\/plus-return-confirm\.js/);
});
test('GoPay approve executor receives debugger click and manual OTP helpers', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /createGoPayApproveExecutor\(\{[\s\S]*clickWithDebugger[\s\S]*requestGoPayOtpInput[\s\S]*\}\)/);
assert.match(source, /REQUEST_GOPAY_OTP_INPUT/);
});
+111
View File
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/gopay-approve.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
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;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
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);
}
test('GoPay OTP always requests manual confirmation even when a previous code exists', () => {
const body = extractFunction('requestManualGoPayOtp');
assert.doesNotMatch(body, /if\s*\(existingCode\)\s*\{\s*return existingCode;\s*\}/);
assert.match(body, /requestGoPayOtpInput\(\{ code: existingCode \}\)/);
assert.match(body, /检测到上次保存的 GoPay 验证码/);
});
test('GoPay approve handles final payment details iframe as an action frame', () => {
assert.match(source, /GOPAY_PAYMENT_FRAME_URL_PATTERN/);
assert.match(source, /payment\\\/details/);
assert.match(source, /app\\\/challenge/);
assert.match(source, /inspectGoPayFramesByDom/);
assert.match(source, /getGoPayDomFramePriority/);
assert.match(source, /paymentFrames/);
assert.match(source, /frameState\?\.hasPayNowButton/);
assert.match(source, /getGoPayDomFrameKind/);
assert.match(source, /return 'payment'/);
assert.match(source, /sendGoPayFrameCommand\(tabId, actionFrameId, 'GOPAY_CLICK_PAY_NOW'/);
assert.match(source, /getGoPayDebuggerTargets/);
assert.match(source, /chrome\.debugger\.getTargets/);
assert.match(source, /targetId: picked\.targetId/);
assert.match(source, /sendGoPayDebuggerTargetCommand\(actionTargetId, 'GOPAY_CLICK_PAY_NOW'/);
assert.match(source, /sendGoPayDebuggerTargetCommand\(actionTargetId, 'GOPAY_SUBMIT_PIN'/);
assert.match(source, /Input\.insertText/);
assert.match(source, /最终 Bayar 确认/);
});
test('GoPay approve treats merchant validate-pin iframe as PIN entry frame', () => {
assert.match(source, /GOPAY_PIN_FRAME_URL_PATTERN/);
assert.match(source, /payment\\\/validate-pin/);
assert.match(source, /kind: 'pin'/);
assert.match(source, /GOPAY_SUBMIT_PIN/);
});
test('GoPay approve closes terminal checkout but does not restart on top-level Pay now alone', () => {
assert.match(source, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(source, /restartGoPayCheckoutFromStep6/);
assert.match(source, /chrome\?\.tabs\?\.remove/);
assert.match(source, /handleGoPayTerminalError\(pageState, tabId\)/);
assert.match(source, /nextState\.hasTerminalError/);
assert.doesNotMatch(source, /GoPay 顶层 Pay now 兜底点击后仍未进入下一步,当前支付会话需要重新创建/);
});
test('GoPay approve falls back to clicking Bayar inside any iframe before top-level Pay now retry', () => {
assert.match(source, /clickGoPayPayButtonInAnyFrame/);
assert.match(source, /data-testid/);
assert.match(source, /pay-button/);
assert.match(source, /已在 GoPay iframe 中点击 Bayar 按钮/);
assert.match(source, /不再自动回退步骤 6/);
});
test('GoPay approve does not treat phone linking page as debugger iframe action', () => {
assert.match(source, /type === 'tel'/);
assert.match(source, /const hasContinueButton = !hasPayNowButton && !hasPhoneInput/);
assert.match(source, /filter\(\(target\) => target\.type === 'iframe'\)/);
});
test('background auto-run routes GoPay restart sentinel back to step 6', () => {
const backgroundSource = fs.readFileSync('background.js', 'utf8');
assert.match(backgroundSource, /isGoPayCheckoutRestartRequiredFailure/);
assert.match(backgroundSource, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(backgroundSource, /step === 8 && isGoPayCheckoutRestartRequiredFailure\(err\)/);
assert.match(backgroundSource, /step = 6/);
assert.match(backgroundSource, /invalidateDownstreamAfterStepRestart\(5/);
});
+341
View File
@@ -0,0 +1,341 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/gopay-flow.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
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 ${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);
}
test('GoPay human click helper dispatches pointer and mouse sequence before native click', async () => {
const bundle = [
extractFunction('dispatchPointerMouseSequence'),
extractFunction('humanClickElement'),
].join('\n');
const events = [];
const button = {
tagName: 'BUTTON',
scrollIntoView() { events.push('scroll'); },
focus() { events.push('focus'); },
click() { events.push('native-click'); },
getBoundingClientRect() { return { left: 10, top: 20, width: 100, height: 40 }; },
dispatchEvent(event) {
events.push(event.type);
return true;
},
};
const api = new Function('button', 'events', `
const window = { screenX: 0, screenY: 0 };
class MouseEvent { constructor(type, init = {}) { this.type = type; this.init = init; } }
class PointerEvent extends MouseEvent {}
async function sleep() { events.push('sleep'); }
${bundle}
return { humanClickElement };
`)(button, events);
await api.humanClickElement(button, { beforeMs: 1, afterDispatchMs: 1, afterMs: 1 });
assert.deepEqual(events.slice(0, 3), ['scroll', 'sleep', 'focus']);
assert.ok(events.includes('pointerdown'));
assert.ok(events.includes('mousedown'));
assert.ok(events.includes('mouseup'));
assert.ok(events.includes('click'));
assert.equal(events.at(-2), 'native-click');
});
test('GoPay continue target exposes a debugger-clickable rect', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findContinueButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayContinueTarget'),
].join('\n');
const button = {
tagName: 'BUTTON',
id: 'link-and-pay',
className: 'btn primary',
textContent: 'Link and pay',
innerText: 'Link and pay',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'class') return this.className;
return '';
},
getBoundingClientRect() { return { left: 20, top: 30, width: 160, height: 44 }; },
};
const api = new Function('button', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 390,
innerHeight: 844,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('[role="button"]') ? [button] : [];
},
};
${bundle}
return { getGoPayContinueTarget };
`)(button);
const target = api.getGoPayContinueTarget();
assert.equal(target.found, true);
assert.equal(target.rect.centerX, 100);
assert.equal(target.rect.centerY, 52);
assert.match(target.target, /Link and pay/);
});
test('GoPay PIN page detection wins over generic pin-input OTP attributes', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getPageBodyText'),
extractFunction('isGoPayOtpPageText'),
extractFunction('isGoPayPinPageText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('getVisibleTextInputs'),
extractFunction('isCountrySearchInput'),
extractFunction('getCombinedElementText'),
extractFunction('findInputByPatterns'),
extractFunction('findOtpInput'),
extractFunction('getGoPayPinInputs'),
extractFunction('findPinInput'),
].join('\n');
const pinInputs = Array.from({ length: 6 }, (_, index) => ({
tagName: 'INPUT',
type: 'text',
id: '',
className: 'pin-input password',
textContent: '',
value: '',
placeholder: '○',
maxLength: 1,
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'maxlength') return '1';
if (name === 'data-testid') return `pin-input-${index}`;
if (name === 'class') return this.className;
if (name === 'placeholder') return this.placeholder;
return '';
},
getBoundingClientRect() { return { width: 40, height: 40 }; },
}));
const api = new Function('pinInputs', `
const location = { href: 'https://pin-web-client.gopayapi.com/auth/pin/verify' };
const window = { getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; } };
const document = {
body: { innerText: 'Silakan ketik 6 digit PIN kamu buat lanjut. Lupa PIN', textContent: 'Silakan ketik 6 digit PIN kamu buat lanjut. Lupa PIN' },
querySelectorAll(selector) { return selector.includes('input') ? pinInputs : []; },
};
${bundle}
return { isGoPayOtpPageText, isGoPayPinPageText, findOtpInput, findPinInput, getGoPayPinInputs };
`)(pinInputs);
assert.equal(api.isGoPayPinPageText(), true);
assert.equal(api.isGoPayOtpPageText(), false);
assert.equal(api.findOtpInput(), null);
assert.equal(api.findPinInput(), pinInputs[0]);
assert.equal(api.getGoPayPinInputs().length, 6);
});
test('GoPay Pay now button is detected separately from generic continue actions', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayPayNowTarget'),
].join('\n');
const payButton = {
tagName: 'BUTTON',
id: '',
className: 'btn full primary btn-theme',
textContent: 'Pay now',
innerText: 'Pay now',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) { return name === 'class' ? this.className : ''; },
getBoundingClientRect() { return { left: 411, top: 689, width: 388, height: 38 }; },
};
const refreshButton = {
...payButton,
className: 'refresh-button',
textContent: 'Refresh',
innerText: 'Refresh',
getBoundingClientRect() { return { left: 705, top: 346, width: 90, height: 30 }; },
};
const api = new Function('payButton', 'refreshButton', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 1280,
innerHeight: 800,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('[role="button"]') ? [refreshButton, payButton] : [];
},
};
${bundle}
return { findPayNowButton, getGoPayPayNowTarget };
`)(payButton, refreshButton);
assert.equal(api.findPayNowButton(), payButton);
assert.equal(api.getGoPayPayNowTarget().found, true);
assert.match(api.getGoPayPayNowTarget().target, /Pay now/);
});
test('GoPay final Bayar amount button is detected without matching terms link', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
].join('\n');
const bayarButton = {
tagName: 'BUTTON',
className: 'bg-brand text-white',
textContent: 'Bayar\nRp 1',
innerText: 'Bayar\nRp 1',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) { return name === 'class' ? this.className : ''; },
getBoundingClientRect() { return { left: 16, top: 556, width: 388, height: 44 }; },
};
const termsLink = {
...bayarButton,
tagName: 'A',
className: 'font-semibold text-brand cursor-pointer',
textContent: 'Syarat & Ketentuan',
innerText: 'Syarat & Ketentuan',
getBoundingClientRect() { return { left: 224, top: 608, width: 104, height: 16 }; },
};
const api = new Function('bayarButton', 'termsLink', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('a') || selector.includes('[role="button"]') ? [termsLink, bayarButton] : [];
},
};
${bundle}
return { findPayNowButton };
`)(bayarButton, termsLink);
assert.equal(api.findPayNowButton(), bayarButton);
});
test('GoPay terminal timeout page is reported as retry-required state', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getPageBodyText'),
extractFunction('isGoPayPinPageText'),
extractFunction('detectGoPayTerminalError'),
extractFunction('isGoPayOtpPageText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('getVisibleTextInputs'),
extractFunction('findInputByPatterns'),
extractFunction('findPhoneInput'),
extractFunction('isCountrySearchInput'),
extractFunction('findOtpInput'),
extractFunction('getCombinedElementText'),
extractFunction('getGoPayPinInputs'),
extractFunction('findPinInput'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
extractFunction('findContinueButton'),
extractFunction('readSelectedCountryCodeText'),
extractFunction('inspectGoPayState'),
].join('\n');
const api = new Function(`
const location = { href: 'https://merchants-gws-app.gopayapi.com/app/challenge?reference=test' };
const window = { getComputedStyle() { return { display: 'none', visibility: 'hidden', opacity: '0' }; } };
const document = {
body: { innerText: 'Yah, waktunya habis\\nKalau kamu mau coba lagi, tutup halaman ini dan ulangi prosesnya dari awal, ya.', textContent: '' },
readyState: 'complete',
querySelectorAll() { return []; },
};
${bundle}
return { inspectGoPayState, detectGoPayTerminalError };
`)();
const state = api.inspectGoPayState();
assert.equal(state.hasTerminalError, true);
assert.equal(state.terminalError.code, 'expired');
assert.match(state.terminalError.message, /重新创建 Plus Checkout|超时/);
});
+15
View File
@@ -0,0 +1,15 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadGoPayUtils() {
const source = fs.readFileSync('gopay-utils.js', 'utf8');
const globalScope = {};
return new Function('self', `${source}; return self.GoPayUtils;`)(globalScope);
}
test('GoPay utils normalize manual OTP input', () => {
const api = loadGoPayUtils();
assert.equal(api.normalizeGoPayOtp(' 12-34 56 '), '123456');
assert.equal(api.normalizeGoPayOtp('abc'), '');
});
+195
View File
@@ -38,6 +38,121 @@ test('plus checkout content script can be injected repeatedly on the same page',
assert.equal(context.__MULTIPAGE_PLUS_CHECKOUT_READY__, true);
});
function createPlusCheckoutMessageHarness({ checkoutSessionId = 'cs_test_123' } = {}) {
const attrs = new Map();
let listener = null;
const fetchCalls = [];
const context = {
console: { log() {}, warn() {}, error() {}, info() {} },
location: { href: 'https://chatgpt.com/' },
window: {},
document: {
readyState: 'complete',
documentElement: {
getAttribute(name) {
return attrs.get(name) || null;
},
setAttribute(name, value) {
attrs.set(name, String(value));
},
},
},
chrome: {
runtime: {
onMessage: {
addListener(fn) {
listener = fn;
},
},
},
},
resetStopState() {},
isStopError() { return false; },
throwIfStopped() {},
sleep() { return Promise.resolve(); },
log() {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === '/api/auth/session') {
return {
ok: true,
status: 200,
json: async () => ({ accessToken: 'test-access-token' }),
};
}
if (url === 'https://chatgpt.com/backend-api/payments/checkout') {
return {
ok: true,
status: 200,
json: async () => ({ checkout_session_id: checkoutSessionId }),
};
}
throw new Error(`unexpected fetch url: ${url}`);
},
};
context.window = context;
vm.createContext(context);
vm.runInContext(source, context);
assert.equal(typeof listener, 'function');
async function send(message) {
return await new Promise((resolve) => {
listener(message, {}, resolve);
});
}
return { send, fetchCalls };
}
test('CREATE_PLUS_CHECKOUT keeps PayPal on DE/EUR and openai_ie merchant path by default', async () => {
const harness = createPlusCheckoutMessageHarness({ checkoutSessionId: 'cs_paypal' });
const result = await harness.send({
type: 'CREATE_PLUS_CHECKOUT',
source: 'test',
payload: {},
});
assert.equal(result.ok, true);
assert.equal(result.checkoutUrl, 'https://chatgpt.com/checkout/openai_ie/cs_paypal');
assert.equal(result.country, 'DE');
assert.equal(result.currency, 'EUR');
const checkoutCall = harness.fetchCalls.find((call) => call.url === 'https://chatgpt.com/backend-api/payments/checkout');
assert.ok(checkoutCall);
assert.equal(checkoutCall.options.method, 'POST');
assert.equal(checkoutCall.options.headers.Authorization, 'Bearer test-access-token');
const payload = JSON.parse(checkoutCall.options.body);
assert.equal(payload.plan_name, 'chatgptplusplan');
assert.deepEqual(payload.billing_details, { country: 'DE', currency: 'EUR' });
});
test('CREATE_PLUS_CHECKOUT uses ID/IDR and openai_llc merchant path for GoPay', async () => {
const harness = createPlusCheckoutMessageHarness({ checkoutSessionId: 'cs_gopay' });
const result = await harness.send({
type: 'CREATE_PLUS_CHECKOUT',
source: 'test',
payload: { paymentMethod: 'gopay' },
});
assert.equal(result.ok, true);
assert.equal(result.checkoutUrl, 'https://chatgpt.com/checkout/openai_llc/cs_gopay');
assert.equal(result.country, 'ID');
assert.equal(result.currency, 'IDR');
const checkoutCall = harness.fetchCalls.find((call) => call.url === 'https://chatgpt.com/backend-api/payments/checkout');
assert.ok(checkoutCall);
const payload = JSON.parse(checkoutCall.options.body);
assert.equal(payload.entry_point, 'all_plans_pricing_modal');
assert.equal(payload.checkout_ui_mode, 'custom');
assert.deepEqual(payload.billing_details, { country: 'ID', currency: 'IDR' });
assert.deepEqual(payload.promo_campaign, {
promo_campaign_id: 'plus-1-month-free',
is_coupon_from_query_param: false,
});
});
function extractFunction(name) {
const plainStart = source.indexOf(`function ${name}(`);
const asyncStart = source.indexOf(`async function ${name}(`);
@@ -251,6 +366,9 @@ test('getCheckoutAmountSummary accepts zero today due amount', () => {
test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
const bundle = [
"const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';",
"const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';",
"const PAYMENT_METHOD_CONFIGS = { paypal: { id: 'paypal', label: 'PayPal', patterns: [/paypal/i] }, gopay: { id: 'gopay', label: 'GoPay', patterns: [/gopay|go\\\\s*pay/i] } };",
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
@@ -260,9 +378,14 @@ test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
extractFunction('getVisibleControls'),
extractFunction('getVisibleTextInputs'),
extractFunction('isDocumentLevelContainer'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getPaymentMethodConfig'),
extractFunction('getPaymentMethodSearchCandidates'),
extractFunction('getPayPalSearchCandidates'),
extractFunction('hasCreditCardFields'),
extractFunction('hasSelectedPaymentMethodControl'),
extractFunction('hasSelectedPayPalControl'),
extractFunction('isPaymentMethodActive'),
extractFunction('isPayPalPaymentMethodActive'),
].join('\n');
@@ -579,6 +702,78 @@ return { findCountryDropdown, findRegionDropdown, matchesCountryOption, matchesR
assert.equal(api.matchesRegionOption('東京都', 'Tokyo'), true);
});
test('payment method helpers can find and confirm selected GoPay controls', () => {
const bundle = [
"const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';",
"const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';",
"const PAYMENT_METHOD_CONFIGS = { paypal: { id: 'paypal', label: 'PayPal', patterns: [/paypal/i] }, gopay: { id: 'gopay', label: 'GoPay', patterns: [/gopay|go\\\\s*pay/i] } };",
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getSearchText'),
extractFunction('getFieldText'),
extractFunction('getCombinedSearchText'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('isDocumentLevelContainer'),
extractFunction('isPaymentCardSized'),
extractFunction('findInteractiveAncestor'),
extractFunction('findPaymentCardAncestor'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getPaymentMethodConfig'),
extractFunction('getPaymentMethodSearchCandidates'),
extractFunction('getGoPaySearchCandidates'),
extractFunction('findPaymentMethodTarget'),
extractFunction('findGoPayPaymentMethodTarget'),
extractFunction('hasSelectedPaymentMethodControl'),
extractFunction('hasSelectedGoPayControl'),
extractFunction('isPaymentMethodActive'),
extractFunction('isGoPayPaymentMethodActive'),
].join('\n');
const gopayButton = createElement({
text: 'GoPay',
attrs: {
id: 'gopay-tab',
role: 'tab',
'data-testid': 'gopay',
'aria-selected': 'true',
value: 'gopay',
},
});
const elements = [gopayButton];
const documentMock = {
documentElement: {},
body: {},
querySelectorAll: (selector) => {
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', `
function findClickableByText(patterns) {
return elements.find((el) => patterns.some((pattern) => pattern.test(getCombinedSearchText(el)))) || null;
}
const elements = document.querySelectorAll('*');
${bundle}
return { findGoPayPaymentMethodTarget, getGoPaySearchCandidates, hasSelectedGoPayControl, isGoPayPaymentMethodActive };
`)(windowMock, documentMock, cssMock);
assert.equal(api.findGoPayPaymentMethodTarget(), gopayButton);
assert.equal(api.getGoPaySearchCandidates()[0], gopayButton);
assert.equal(api.hasSelectedGoPayControl(), true);
assert.equal(api.isGoPayPaymentMethodActive(), true);
});
test('fillIfEmpty can overwrite invalid structured address values in the dropdown branch', () => {
const bundle = [
extractFunction('fillIfEmpty'),
@@ -36,6 +36,20 @@ function createAuAddressSeed() {
};
}
function createIdAddressSeed() {
return {
countryCode: 'ID',
query: 'Jakarta Indonesia',
suggestionIndex: 1,
fallback: {
address1: 'Jalan M.H. Thamrin No. 1',
city: 'Jakarta',
region: 'DKI Jakarta',
postalCode: '10310',
},
};
}
function createSuccessfulBillingResult() {
return {
countryText: 'Germany',
@@ -54,6 +68,7 @@ function createExecutorHarness({
fetchImpl = null,
getAddressSeedForCountry = () => createAddressSeed(),
markCurrentRegistrationAccountUsed = async () => {},
submitRedirectUrl = 'https://www.paypal.com/checkoutnow',
}) {
const api = loadPlusCheckoutBillingModule();
const events = {
@@ -102,7 +117,7 @@ function createExecutorHarness({
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
}
if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') {
checkoutTab.url = 'https://www.paypal.com/checkoutnow';
checkoutTab.url = submitRedirectUrl;
}
return createSuccessfulBillingResult();
},
@@ -131,8 +146,8 @@ function createExecutorHarness({
waitForTabCompleteUntilStopped: async () => checkoutTab,
waitForTabUrlMatchUntilStopped: async (tabId, matcher) => {
events.waitedUrls.push({ tabId });
assert.equal(matcher('https://www.paypal.com/checkoutnow'), true);
return { id: tabId, url: 'https://www.paypal.com/checkoutnow' };
assert.equal(matcher(submitRedirectUrl), true);
return { id: tabId, url: submitRedirectUrl };
},
});
@@ -225,6 +240,106 @@ test('Plus checkout billing sends the billing command to the iframe that contain
assert.equal(events.completed[0].step, 7);
});
test('Plus checkout billing forces Indonesia address for GoPay even when page country differs', async () => {
const requestedCountries = [];
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/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: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'United States',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return countryValue === 'ID' ? createIdAddressSeed() : createAddressSeed();
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: 'Jl. M.H. Thamrin No. 10',
City: 'Jakarta',
State: 'DKI Jakarta',
Zip_Code: '10310',
},
}),
};
},
submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking',
});
await executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gopay', plusCheckoutCountry: 'US' });
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(requestedCountries[0], 'ID');
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID');
assert.equal(fillMessage.message.payload.addressSeed.source, 'meiguodizhi');
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Jakarta',
path: '/id-address',
method: 'refresh',
});
});
test('Plus checkout billing selects GoPay and waits for a GoPay redirect', async () => {
const { checkoutTab, 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: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'Indonesia',
},
},
getAddressSeedForCountry: () => createIdAddressSeed(),
fetchImpl: async () => ({
ok: false,
status: 404,
json: async () => ({ status: 'error' }),
}),
submitRedirectUrl: 'https://gopay.co.id/payment/session',
});
await executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gopay' });
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_GOPAY');
const paypalSelectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
const subscribeMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE');
assert.equal(selectMessage.frameId, 7);
assert.equal(selectMessage.message.payload.paymentMethod, 'gopay');
assert.equal(paypalSelectMessage, undefined);
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID');
assert.equal(subscribeMessage.message.payload.paymentMethod, 'gopay');
assert.equal(checkoutTab.url, 'https://gopay.co.id/payment/session');
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: [
+9 -1
View File
@@ -15,12 +15,20 @@ test('sidepanel loads reusable form dialog and paypal manager before sidepanel b
assert.ok(managerIndex < sidepanelIndex);
});
test('sidepanel html contains paypal select and add button controls', () => {
test('sidepanel html contains paypal select and GoPay controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="row-plus-payment-method"/);
assert.match(html, /id="select-plus-payment-method"/);
assert.match(html, /id="row-paypal-account"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="row-gopay-phone"/);
assert.match(html, /id="input-gopay-phone"/);
assert.match(html, /id="row-gopay-otp"/);
assert.match(html, /id="input-gopay-otp"/);
assert.match(html, /id="row-gopay-pin"/);
assert.match(html, /id="input-gopay-pin"/);
assert.match(html, /id="shared-form-modal"/);
});
+8 -3
View File
@@ -55,6 +55,10 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
);
assert.equal(plusSteps.some((step) => step.key === 'clear-login-cookies'), false);
assert.equal(plusSteps.some((step) => step.key === 'fetch-login-code'), true);
assert.equal(plusSteps.find((step) => step.key === 'paypal-approve')?.title, 'PayPal 登录与授权');
const goPaySteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'gopay' });
assert.equal(goPaySteps.find((step) => step.key === 'paypal-approve')?.title, 'GoPay 手机验证与授权');
assert.equal(api.getStepById(8, { plusModeEnabled: true, plusPaymentMethod: 'gopay' })?.title, 'GoPay 手机验证与授权');
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
assert.equal(api.getLastStepId({ plusModeEnabled: true }), 13);
assert.equal(plusSteps[5].title, '创建 Plus Checkout');
@@ -92,13 +96,14 @@ test('sidepanel html loads shared step definitions before sidepanel bootstrap',
assert.ok(definitionsIndex < sidepanelIndex);
});
test('sidepanel html exposes Plus mode payment controls and PayPal settings', () => {
test('sidepanel html exposes Plus mode, PayPal, and GoPay settings', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="input-plus-mode-enabled"/);
assert.match(html, /id="select-plus-payment-method"/);
assert.match(html, /<option value="paypal">PayPal 支付<\/option>/);
assert.match(html, /<option value="gopay">GoPay 支付<\/option>/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="input-gopay-phone"/);
assert.match(html, /id="input-gopay-otp"/);
assert.match(html, /id="input-gopay-pin"/);
assert.match(html, /id="shared-form-modal"/);
});