feat(gopay): support GoPay Plus checkout flow
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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|超时/);
|
||||
});
|
||||
@@ -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'), '');
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user