Merge branch 'dev'

This commit is contained in:
QLHazyCoder
2026-05-20 13:18:51 +08:00
26 changed files with 3061 additions and 79 deletions
@@ -230,6 +230,7 @@ return {
assert.equal(api.normalizePersistentSettingValue('phoneSignupReloginAfterBindEmailEnabled', 0), false);
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'gopay'), 'gopay');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'gpc-helper'), 'gpc-helper');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'paypal-hosted'), 'paypal-hosted');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'paypal'), 'paypal');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'unknown'), 'paypal');
assert.equal(api.normalizePersistentSettingValue('plusAccountAccessStrategy', 'sub2api_codex_session'), 'sub2api_codex_session');
+29
View File
@@ -127,6 +127,35 @@ return { shouldReportReadyForFrame };
assert.equal(api.shouldReportReadyForFrame('unknown-source', true), false);
});
test('simulateClick logs the button text captured before click side effects', () => {
const bundle = [extractFunction('simulateClick')].join('\n');
const logs = [];
const consoleMessages = [];
const api = new Function('logs', 'console', 'location', `
function throwIfStopped() {}
const LOG_PREFIX = '[test]';
function log(message) { logs.push(message); }
${bundle}
return { simulateClick };
`)(logs, { log: (...args) => consoleMessages.push(args.join(' ')) }, { pathname: '/checkout' });
const button = {
tagName: 'BUTTON',
textContent: '订阅',
getAttribute: () => '',
click() {
this.textContent = '正在处理';
},
};
api.simulateClick(button);
assert.equal(button.textContent, '正在处理');
assert.equal(logs.at(-1), '已点击(click) [BUTTON] "订阅"');
assert.match(consoleMessages.at(-1), /BUTTON 订阅/);
assert.doesNotMatch(logs.at(-1), /正在处理/);
});
test('getRuntimeScriptSource follows injected source overrides after utils is already loaded', () => {
const bundle = [extractFunction('getRuntimeScriptSource')].join('\n');
const api = new Function('window', 'SCRIPT_SOURCE', `
+32 -7
View File
@@ -212,7 +212,7 @@ test('flow capability registry exposes editable Plus account access strategies f
assert.deepEqual(
capabilityState.availablePlusAccountAccessStrategies,
['oauth', 'sub2api_codex_session']
['oauth', 'cpa_codex_session', 'sub2api_codex_session']
);
assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'sub2api_codex_session');
@@ -236,7 +236,7 @@ test('flow capability registry exposes editable Plus account access strategies f
assert.deepEqual(
capabilityState.availablePlusAccountAccessStrategies,
['oauth', 'cpa_codex_session']
['oauth', 'cpa_codex_session', 'sub2api_codex_session']
);
assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'cpa_codex_session');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'cpa_codex_session');
@@ -244,7 +244,7 @@ test('flow capability registry exposes editable Plus account access strategies f
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'cpa_codex_session');
});
test('flow capability registry forces OAuth when the current target only supports OAuth', () => {
test('flow capability registry keeps Plus account access strategies independent from the current target', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
@@ -258,9 +258,34 @@ test('flow capability registry forces OAuth when the current target only support
},
});
assert.deepEqual(capabilityState.availablePlusAccountAccessStrategies, ['oauth']);
assert.deepEqual(
capabilityState.availablePlusAccountAccessStrategies,
['oauth', 'cpa_codex_session', 'sub2api_codex_session']
);
assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'cpa_codex_session');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'oauth');
assert.equal(capabilityState.canEditPlusAccountAccessStrategy, false);
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'oauth');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'cpa_codex_session');
assert.equal(capabilityState.canEditPlusAccountAccessStrategy, true);
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'cpa_codex_session');
});
test('flow capability registry forces SUB2API session import only for contribution mode Plus runs', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
const capabilityState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'openai',
openaiIntegrationTargetId: 'cpa',
signupMethod: 'email',
plusModeEnabled: true,
accountContributionEnabled: true,
plusAccountAccessStrategy: 'cpa_codex_session',
},
});
assert.deepEqual(capabilityState.availablePlusAccountAccessStrategies, ['sub2api_codex_session']);
assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'cpa_codex_session');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(capabilityState.canEditPlusAccountAccessStrategy, false);
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'sub2api_codex_session');
});
@@ -87,6 +87,30 @@ test('settings schema normalizes view input into canonical nested namespaces', (
});
});
test('settings schema lets explicit flat step range override stale canonical range', () => {
const { settingsSchema } = loadApis();
const schema = settingsSchema.createSettingsSchema();
const oldState = schema.normalizeSettingsState({
activeFlowId: 'openai',
stepExecutionRangeByFlow: {
openai: { enabled: true, fromStep: 3, toStep: 6 },
},
});
const normalized = schema.normalizeSettingsState({
settingsState: oldState,
stepExecutionRangeByFlow: {
openai: { enabled: false, fromStep: 3, toStep: 6 },
},
});
assert.deepEqual(normalized.flows.openai.autoRun.stepExecutionRange, {
enabled: false,
fromStep: 3,
toStep: 6,
});
});
test('settings schema can project canonical state into a read view without legacy rebuild helpers', () => {
const { settingsSchema } = loadApis();
const schema = settingsSchema.createSettingsSchema();
+2
View File
@@ -23,6 +23,8 @@ test('GoPay utils normalize manual OTP input', () => {
test('GoPay utils keeps GPC helper payment method distinct', () => {
const api = loadGoPayUtils();
assert.equal(api.normalizePlusPaymentMethod('paypal-hosted'), 'paypal-hosted');
assert.equal(api.normalizePlusPaymentMethod('paypal_direct'), 'paypal-hosted');
assert.equal(api.normalizePlusPaymentMethod('gpc-helper'), 'gpc-helper');
assert.equal(api.normalizePlusPaymentMethod('gopay'), 'gopay');
assert.equal(api.normalizePlusPaymentMethod('unknown'), 'paypal');
+379
View File
@@ -1,6 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('content/paypal-flow.js', 'utf8');
@@ -308,3 +309,381 @@ test('PayPal email submit refills a prefilled email before clicking next', async
awaiting: 'password_page',
});
});
function createHostedPayPalHarness(options = {}) {
const events = [];
const attrs = new Map();
const elementsById = new Map();
let elements = [];
let listener = null;
const body = { innerText: '', textContent: '' };
const location = {
href: 'https://www.paypal.com/checkoutweb/signup',
host: 'www.paypal.com',
pathname: '/checkoutweb/signup',
};
function createDomElement({
tagName = 'DIV',
id = '',
type = '',
name = '',
text = '',
value = '',
attrs: initialAttrs = {},
options: selectOptions = [],
} = {}) {
const attrMap = new Map(Object.entries(initialAttrs));
const element = {
nodeType: 1,
tagName,
id,
type,
name,
textContent: text,
innerText: text,
value,
checked: false,
disabled: false,
hidden: false,
options: selectOptions,
parentElement: null,
style: { display: 'block', visibility: 'visible', opacity: '1' },
getAttribute(key) {
if (key === 'id') return this.id;
if (key === 'type') return this.type;
if (key === 'name') return this.name;
if (key === 'placeholder') return attrMap.get('placeholder') || '';
return attrMap.has(key) ? attrMap.get(key) : null;
},
setAttribute(key, nextValue) {
attrMap.set(key, String(nextValue));
},
dispatchEvent() {
return true;
},
focus() {},
blur() {},
click() {
events.push({ type: 'native-click', id: this.id, text: this.textContent });
},
getBoundingClientRect() {
return { left: 10, top: 10, width: 180, height: 44 };
},
};
if (id) elementsById.set(id, element);
return element;
}
const countrySelect = createDomElement({ tagName: 'SELECT', id: 'country', value: 'US' });
const emailInput = createDomElement({ tagName: 'INPUT', id: 'email', type: 'email', name: 'email' });
const phoneInput = createDomElement({ tagName: 'INPUT', id: 'phone', type: 'tel', name: 'phone' });
const cardNumberInput = createDomElement({ tagName: 'INPUT', id: 'cardNumber', type: 'text' });
const cardExpiryInput = createDomElement({ tagName: 'INPUT', id: 'cardExpiry', type: 'text' });
const cardCvvInput = createDomElement({ tagName: 'INPUT', id: 'cardCvv', type: 'text' });
const passwordInput = createDomElement({ tagName: 'INPUT', id: 'password', type: 'password' });
const firstNameInput = createDomElement({ tagName: 'INPUT', id: 'firstName', type: 'text' });
const lastNameInput = createDomElement({ tagName: 'INPUT', id: 'lastName', type: 'text' });
const billingLine1Input = createDomElement({ tagName: 'INPUT', id: 'billingLine1', type: 'text' });
const billingCityInput = createDomElement({ tagName: 'INPUT', id: 'billingCity', type: 'text' });
const billingPostalCodeInput = createDomElement({ tagName: 'INPUT', id: 'billingPostalCode', type: 'text' });
const billingStateSelect = createDomElement({
tagName: 'SELECT',
id: 'billingState',
value: '',
options: [
{ textContent: 'New York', label: 'New York', value: 'NY' },
{ textContent: 'California', label: 'California', value: 'CA' },
],
});
const submitButton = createDomElement({
tagName: 'BUTTON',
id: 'hostedSubmit',
text: 'Agree & Create Account',
attrs: { 'data-testid': 'submit-button' },
});
const createAccountButton = createDomElement({
tagName: 'BUTTON',
id: 'createAccountButton',
text: 'Agree & Create Account',
attrs: { 'data-testid': 'createAccountButton' },
});
const nextButton = createDomElement({
tagName: 'BUTTON',
id: 'btnNext',
text: '下一页',
});
function setElements(nextElements) {
elements = nextElements;
elementsById.clear();
for (const element of nextElements) {
if (element.id) elementsById.set(element.id, element);
}
}
function showGuestCheckout() {
location.href = 'https://www.paypal.com/checkoutweb/signup';
location.host = 'www.paypal.com';
location.pathname = '/checkoutweb/signup';
body.innerText = 'Pay with debit or credit card';
body.textContent = body.innerText;
setElements([
countrySelect,
emailInput,
phoneInput,
cardNumberInput,
cardExpiryInput,
cardCvvInput,
passwordInput,
firstNameInput,
lastNameInput,
billingLine1Input,
billingCityInput,
billingPostalCodeInput,
billingStateSelect,
submitButton,
]);
}
function showCreateAccount() {
location.href = 'https://www.paypal.com/checkoutweb/create-account';
location.host = 'www.paypal.com';
location.pathname = '/checkoutweb/create-account';
body.innerText = 'Create your PayPal account. Agree & Create Account';
body.textContent = body.innerText;
setElements([createAccountButton]);
}
function showPayEmail() {
location.href = 'https://www.paypal.com/pay?token=demo';
location.host = 'www.paypal.com';
location.pathname = '/pay';
body.innerText = '请输入您的电子邮箱地址。 下一页 或 创建账户';
body.textContent = body.innerText;
setElements([emailInput, nextButton, createAccountButton]);
}
const context = {
console: { log() {}, warn() {}, error() {}, info() {} },
location,
window: {},
Event: class TestEvent { constructor(type) { this.type = type; } },
MouseEvent: class TestMouseEvent { constructor(type) { this.type = type; } },
PointerEvent: class TestPointerEvent { constructor(type) { this.type = type; } },
document: {
readyState: 'complete',
body,
documentElement: {
getAttribute(name) {
return attrs.get(name) || null;
},
setAttribute(name, nextValue) {
attrs.set(name, String(nextValue));
},
},
getElementById(id) {
return elementsById.get(id) || null;
},
querySelector(selector) {
const text = String(selector || '');
if (text.includes('createAccountButton') || text.includes('create-account-button')) {
return elements.includes(createAccountButton) ? createAccountButton : null;
}
if (text.includes('submit-button') || text.includes('hosted-payment-submit-button')) {
return elements.includes(submitButton) ? submitButton : null;
}
return null;
},
querySelectorAll(selector) {
const text = String(selector || '');
if (text === 'input') return elements.filter((element) => element.tagName === 'INPUT');
if (text === 'input[type="email"]') return elements.filter((element) => element.type === 'email');
if (text === 'input[type="password"]') return elements.filter((element) => element.type === 'password');
if (text.includes('button') || text.includes('[role="button"]')) {
return elements.filter((element) => element.tagName === 'BUTTON');
}
return [];
},
},
chrome: {
runtime: {
onMessage: {
addListener(fn) {
listener = fn;
},
},
},
},
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
events.push({ type: 'operation', metadata });
const result = await operation();
events.push({ type: 'delay', metadata });
return result;
},
},
resetStopState() {},
isStopError() { return false; },
throwIfStopped() {},
sleep() { return Promise.resolve(); },
fillInput(element, value) {
if (element === phoneInput && typeof options.renderPhone === 'function') {
element.value = options.renderPhone(value);
} else {
element.value = value;
}
events.push({ type: 'fill', id: element.id, value: element.value });
},
simulateClick(element) {
events.push({ type: 'click', id: element.id, text: element.textContent });
},
};
context.window = context;
context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible', opacity: '1' };
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 {
events,
send,
showPayEmail,
showCreateAccount,
showGuestCheckout,
};
}
test('PayPal hosted guest checkout verifies configured local phone before submit', async () => {
const harness = createHostedPayPalHarness({
renderPhone: (value) => `+1 ${value}`,
});
harness.showGuestCheckout();
const result = await harness.send({
type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP',
source: 'test',
payload: {
expectedStage: 'guest_checkout',
email: 'guest@example.com',
phone: '4155551234',
cardNumber: '4147200000000000',
cardExpiry: '12 / 29',
cardCvv: '123',
password: 'Aa1!example',
firstName: 'James',
lastName: 'Smith',
address: {
street: '1 Main St',
city: 'New York',
state: 'New York',
zip: '10001',
},
},
});
assert.equal(result.ok, true);
assert.equal(result.submitted, true);
assert.equal(result.phoneMatched, true);
assert.equal(result.payloadPhoneDigits, '4155551234');
assert.equal(result.renderedPhoneDigits, '14155551234');
assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'hostedSubmit'), true);
assert.deepEqual(
JSON.parse(JSON.stringify(harness.events.filter((event) => event.type === 'operation').map((event) => event.metadata))),
[{ stepKey: 'paypal-hosted-card', kind: 'click', label: 'hosted-paypal-card-submit' }]
);
});
test('PayPal hosted guest checkout blocks submit when rendered phone differs from config', async () => {
const harness = createHostedPayPalHarness({
renderPhone: () => '9999999999',
});
harness.showGuestCheckout();
const result = await harness.send({
type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP',
source: 'test',
payload: {
expectedStage: 'guest_checkout',
phone: '4155551234',
cardNumber: '4147200000000000',
cardExpiry: '12 / 29',
cardCvv: '123',
password: 'Aa1!example',
address: { street: '1 Main St', city: 'New York', state: 'New York', zip: '10001' },
},
});
assert.match(result.error, /电话不一致/);
assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'hostedSubmit'), false);
});
test('PayPal hosted /pay email page fills email and clicks Next instead of Create Account', async () => {
const harness = createHostedPayPalHarness();
harness.showPayEmail();
const state = await harness.send({
type: 'PAYPAL_HOSTED_GET_STATE',
source: 'test',
payload: {},
});
assert.equal(state.ok, true);
assert.equal(state.hostedStage, 'pay_login');
assert.equal(state.hasHostedEmailInput, true);
const result = await harness.send({
type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP',
source: 'test',
payload: {
expectedStage: 'pay_login',
email: 'guest@example.com',
},
});
assert.equal(result.ok, true);
assert.equal(result.stage, 'pay_login');
assert.equal(result.submitted, true);
assert.equal(harness.events.some((event) => event.type === 'fill' && event.id === 'email' && event.value === 'guest@example.com'), true);
assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'btnNext'), true);
assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'createAccountButton'), false);
assert.deepEqual(
JSON.parse(JSON.stringify(harness.events.filter((event) => event.type === 'operation').map((event) => event.metadata))),
[{ stepKey: 'paypal-hosted-email', kind: 'click', label: 'hosted-paypal-email-next' }]
);
});
test('PayPal hosted create account page is detected and handled as its own step', async () => {
const harness = createHostedPayPalHarness();
harness.showCreateAccount();
const state = await harness.send({
type: 'PAYPAL_HOSTED_GET_STATE',
source: 'test',
payload: {},
});
assert.equal(state.ok, true);
assert.equal(state.hostedStage, 'create_account');
assert.equal(state.createAccountReady, true);
const result = await harness.send({
type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP',
source: 'test',
payload: { expectedStage: 'create_account' },
});
assert.equal(result.ok, true);
assert.equal(result.stage, 'create_account');
assert.equal(result.submitted, true);
assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'createAccountButton'), true);
assert.deepEqual(
JSON.parse(JSON.stringify(harness.events.filter((event) => event.type === 'operation').map((event) => event.metadata))),
[{ stepKey: 'paypal-hosted-create-account', kind: 'click', label: 'hosted-paypal-create-account' }]
);
});
+12 -8
View File
@@ -115,19 +115,21 @@ return {
`)();
}
test('sidepanel keeps requested Plus account strategy while OAuth-only targets force the effective value', () => {
test('sidepanel keeps requested Plus account strategy while contribution mode forces SUB2API', () => {
const api = buildHarness(
`{
canShowPlusSettings: true,
runtimeLocks: { plusModeEnabled: true },
runtimeLocks: { plusModeEnabled: true, accountContribution: true },
canEditPlusAccountAccessStrategy: false,
effectivePlusAccountAccessStrategy: 'oauth',
availablePlusAccountAccessStrategies: ['sub2api_codex_session'],
effectivePlusAccountAccessStrategy: 'sub2api_codex_session',
}`,
`{
activeFlowId: 'openai',
panelMode: 'cpa',
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
accountContributionEnabled: true,
plusAccountAccessStrategy: 'cpa_codex_session',
}`
);
@@ -135,10 +137,10 @@ test('sidepanel keeps requested Plus account strategy while OAuth-only targets f
assert.equal(api.rowPlusAccountAccessStrategy.style.display, '');
assert.equal(api.selectPlusAccountAccessStrategy.disabled, true);
assert.equal(api.selectPlusAccountAccessStrategy.dataset.requestedValue, 'sub2api_codex_session');
assert.equal(api.selectPlusAccountAccessStrategy.value, 'oauth');
assert.equal(api.getRequestedPlusAccountAccessStrategy(), 'sub2api_codex_session');
assert.match(api.plusAccountAccessStrategyCaption.textContent, /OAuth/);
assert.equal(api.selectPlusAccountAccessStrategy.dataset.requestedValue, 'cpa_codex_session');
assert.equal(api.selectPlusAccountAccessStrategy.value, 'sub2api_codex_session');
assert.equal(api.getRequestedPlusAccountAccessStrategy(), 'cpa_codex_session');
assert.match(api.plusAccountAccessStrategyCaption.textContent, /SUB2API/);
});
test('sidepanel enables SUB2API session strategy selection when the current Plus target supports it', () => {
@@ -283,6 +285,7 @@ return {
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
accountContributionEnabled: false,
},
},
{
@@ -294,6 +297,7 @@ return {
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
accountContributionEnabled: false,
},
},
]);
+79 -2
View File
@@ -38,7 +38,7 @@ 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' } = {}) {
function createPlusCheckoutMessageHarness({ checkoutSessionId = 'cs_test_123', checkoutResponse = null } = {}) {
const attrs = new Map();
let listener = null;
const fetchCalls = [];
@@ -84,7 +84,7 @@ function createPlusCheckoutMessageHarness({ checkoutSessionId = 'cs_test_123' }
return {
ok: true,
status: 200,
json: async () => ({ checkout_session_id: checkoutSessionId }),
json: async () => (checkoutResponse || { checkout_session_id: checkoutSessionId }),
};
}
throw new Error(`unexpected fetch url: ${url}`);
@@ -153,6 +153,40 @@ test('CREATE_PLUS_CHECKOUT uses ID/IDR and openai_llc merchant path for GoPay',
});
});
test('CREATE_PLUS_CHECKOUT uses hosted US/USD checkout for PayPal no-card binding', async () => {
const hostedUrl = 'https://pay.openai.com/c/pay/cs_hosted_123';
const harness = createPlusCheckoutMessageHarness({
checkoutSessionId: 'cs_hosted',
checkoutResponse: {
checkout_session_id: 'cs_hosted',
next_action: {
redirect_to_url: {
url: hostedUrl,
},
},
},
});
const result = await harness.send({
type: 'CREATE_PLUS_CHECKOUT',
source: 'test',
payload: { paymentMethod: 'paypal-hosted' },
});
assert.equal(result.ok, true);
assert.equal(result.checkoutUrl, 'https://chatgpt.com/checkout/openai_llc/cs_hosted');
assert.equal(result.hostedCheckoutUrl, hostedUrl);
assert.equal(result.preferredCheckoutUrl, hostedUrl);
assert.equal(result.country, 'US');
assert.equal(result.currency, 'USD');
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.checkout_ui_mode, 'hosted');
assert.deepEqual(payload.billing_details, { country: 'US', currency: 'USD' });
});
function extractFunction(name) {
const plainStart = source.indexOf(`function ${name}(`);
const asyncStart = source.indexOf(`async function ${name}(`);
@@ -532,6 +566,49 @@ return { findSubscribeButton };
assert.equal(api.findSubscribeButton(), submitButton);
});
test('getSubscribeButtonState does not treat processing subscribe text as clickable', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getSearchText'),
extractFunction('getFieldText'),
extractFunction('getCombinedSearchText'),
extractFunction('isEnabledControl'),
extractFunction('isBusySubscribeButton'),
'const SUBSCRIBE_READY_TEXT_PATTERN = /\\u8ba2\\u9605|\\u7ee7\\u7eed|\\u786e\\u8ba4|\\u652f\\u4ed8|subscribe|continue|confirm|pay|\\u8d2d\\u4e70\\s*ChatGPT\\s*Plus|start\\s*subscription|place\\s*order/i;',
'const SUBSCRIBE_PROCESSING_TEXT_PATTERN = /\\u6b63\\u5728\\u5904\\u7406|\\u5904\\u7406\\u4e2d|\\u8bf7\\u7a0d\\u5019|\\u52a0\\u8f7d\\u4e2d|loading|processing|submitting/i;',
extractFunction('getSubscribeButtonState'),
].join('\n');
const processingButton = createElement({
tagName: 'BUTTON',
text: '订阅 正在处理',
attrs: { type: 'submit' },
});
const readyButton = createElement({
tagName: 'BUTTON',
text: '订阅',
attrs: { type: 'submit' },
});
const api = new Function(`
${bundle}
return { getSubscribeButtonState };
`)();
assert.deepEqual(
{
ready: api.getSubscribeButtonState(processingButton).ready,
status: api.getSubscribeButtonState(processingButton).status,
},
{
ready: false,
status: 'processing',
}
);
assert.equal(api.getSubscribeButtonState(readyButton).ready, true);
});
test('humanLikeClick submits a detached submit button through its form attribute', async () => {
const bundle = [
'function throwIfStopped() {}',
@@ -84,6 +84,7 @@ function createExecutorHarness({
getState = null,
queryTabsInAutomationWindow = null,
markCurrentRegistrationAccountUsed = async () => {},
onClickSubscribe = null,
probeIpProxyExit = null,
onSetState = null,
sleepWithStop = null,
@@ -137,7 +138,14 @@ function createExecutorHarness({
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
}
if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') {
checkoutTab.url = submitRedirectUrl;
if (typeof onClickSubscribe === 'function') {
const clickResult = await onClickSubscribe({ checkoutTab, events, frameId, message, tabId });
if (clickResult !== undefined) {
return clickResult;
}
} else {
checkoutTab.url = submitRedirectUrl;
}
}
return createSuccessfulBillingResult();
},
@@ -242,6 +250,57 @@ test('Plus checkout billing uses the current checkout tab when step 6 did not re
assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true);
});
test('Plus checkout billing waits on processing subscribe text before clicking a ready subscribe button again', async () => {
const originalNow = Date.now;
let now = 0;
let clickCalls = 0;
Date.now = () => now;
try {
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,
},
},
onClickSubscribe: async ({ checkoutTab }) => {
clickCalls += 1;
if (clickCalls === 1) {
return {
clicked: false,
subscribeButtonStatus: 'processing',
subscribeButtonText: '订阅正在处理',
};
}
checkoutTab.url = 'https://www.paypal.com/checkoutnow';
return {
clicked: true,
subscribeButtonStatus: 'clicked',
subscribeButtonText: '订阅',
};
},
sleepWithStop: async (ms) => {
events.sleeps.push(ms);
now += ms;
},
});
await executor.executePlusCheckoutBilling({});
const subscribeMessages = events.messages.filter((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE');
assert.equal(subscribeMessages.length, 2);
assert.equal(subscribeMessages.some((entry) => entry.message.payload.allowBusySubscribeButton !== undefined), false);
assert.equal(events.sleeps.filter((ms) => ms === 500).length >= 20, true);
assert.equal(events.logs.some((entry) => /本轮未点击/.test(entry.message)), true);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
} finally {
Date.now = originalNow;
}
});
test('Plus checkout billing searches checkout tabs inside the locked automation window', async () => {
const queries = [];
const { checkoutTab, executor } = createExecutorHarness({
+266
View File
@@ -288,6 +288,272 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c
assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' });
});
test('PayPal no-card binding create opens and submits hosted OpenAI checkout before completing', async () => {
const events = [];
let currentUrl = 'https://chatgpt.com/';
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async (message, level = 'info') => {
events.push({ type: 'log', message, level });
},
chrome: {
tabs: {
create: async (payload) => {
events.push({ type: 'tab-create', payload });
return { id: 55, url: payload.url, status: 'complete' };
},
update: async (tabId, payload) => {
currentUrl = payload.url;
events.push({ type: 'tab-update', tabId, payload });
return { id: tabId, url: currentUrl, status: 'complete' };
},
get: async (tabId) => ({ id: tabId, url: currentUrl, status: 'complete' }),
},
},
completeNodeFromBackground: async (step, payload) => {
events.push({ type: 'complete', step, payload });
},
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => {
events.push({ type: 'ready', source, tabId, options });
},
fetch: async (url) => {
events.push({ type: 'fetch', url });
assert.equal(url, 'https://www.meiguodizhi.com/api/v1/dz');
return {
ok: true,
status: 200,
json: async () => ({
address: {
Address: '1 Main St',
City: 'New York',
State: 'New York',
Zip_Code: '10001',
},
}),
};
},
getState: async () => {
events.push({ type: 'get-state' });
return {
hostedCheckoutPhoneNumber: '4155551234',
};
},
registerTab: async (source, tabId) => {
events.push({ type: 'register', source, tabId });
},
sendTabMessageUntilStopped: async (tabId, source, message) => {
events.push({ type: 'tab-message', tabId, source, message });
if (message.type === 'CREATE_PLUS_CHECKOUT') {
return {
checkoutUrl: 'https://chatgpt.com/checkout/openai_llc/cs_hosted',
preferredCheckoutUrl: 'https://pay.openai.com/c/pay/cs_hosted',
hostedCheckoutUrl: 'https://pay.openai.com/c/pay/cs_hosted',
country: 'US',
currency: 'USD',
};
}
if (message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') {
currentUrl = 'https://www.paypal.com/pay?token=BA-hosted';
return { clicked: true };
}
throw new Error(`unexpected message type ${message.type}`);
},
setState: async (payload) => {
events.push({ type: 'set-state', payload });
},
sleepWithStop: async (ms) => {
events.push({ type: 'sleep', ms });
},
waitForTabCompleteUntilStopped: async () => {
events.push({ type: 'tab-complete' });
},
});
await executor.executePlusCheckoutCreate({
plusPaymentMethod: 'paypal-hosted',
plusHostedCheckoutOauthDelaySeconds: 0,
});
assert.deepStrictEqual(
events.find((event) => event.type === 'tab-message' && event.message.type === 'CREATE_PLUS_CHECKOUT')?.message?.payload,
{ paymentMethod: 'paypal-hosted' }
);
assert.equal(
events.find((event) => event.type === 'tab-update')?.payload?.url,
'https://pay.openai.com/c/pay/cs_hosted'
);
const statePayload = events.filter((event) => event.type === 'set-state').at(-1)?.payload || {};
assert.equal(statePayload.plusCheckoutSource, 'paypal-hosted');
assert.equal(statePayload.plusCheckoutCountry, 'US');
assert.equal(statePayload.plusCheckoutCurrency, 'USD');
assert.equal(statePayload.plusReturnUrl, '');
assert.equal(events.some((event) => event.type === 'tab-message' && event.message.type === 'FILL_PLUS_BILLING_AND_SUBMIT'), false);
assert.equal(events.some((event) => event.type === 'tab-message' && event.message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP'), true);
assert.equal(
events.find((event) => event.type === 'tab-message' && event.message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP')?.message?.payload?.address?.street,
'1 Main St'
);
assert.deepStrictEqual(events.find((event) => event.type === 'complete'), {
type: 'complete',
step: 'plus-checkout-create',
payload: {
plusCheckoutCountry: 'US',
plusCheckoutCurrency: 'USD',
plusCheckoutSource: 'paypal-hosted',
plusCheckoutUrl: 'https://www.paypal.com/pay?token=BA-hosted',
plusReturnUrl: '',
plusHostedCheckoutCompleted: false,
},
});
});
test('PayPal no-card binding OpenAI checkout node submits hosted page and completes after success transition', async () => {
const events = [];
let currentUrl = 'https://pay.openai.com/c/pay/cs_hosted';
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async (message, level = 'info') => {
events.push({ type: 'log', message, level });
},
chrome: {
tabs: {
get: async (tabId) => ({ id: tabId, url: currentUrl, status: 'complete' }),
},
},
completeNodeFromBackground: async (step, payload) => {
events.push({ type: 'complete', step, payload });
},
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => {
events.push({ type: 'ready', source, tabId, options });
},
fetch: async (url) => {
events.push({ type: 'fetch', url });
assert.equal(url, 'https://www.meiguodizhi.com/api/v1/dz');
return {
ok: true,
status: 200,
json: async () => ({
address: {
Address: '1 Main St',
City: 'New York',
State: 'New York',
Zip_Code: '10001',
},
}),
};
},
getState: async () => ({
hostedCheckoutPhoneNumber: '(415) 555-1234',
}),
registerTab: async (source, tabId) => {
events.push({ type: 'register', source, tabId });
},
sendTabMessageUntilStopped: async (tabId, source, message) => {
events.push({ type: 'tab-message', tabId, source, message });
if (message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') {
currentUrl = 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted';
return { clicked: true };
}
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
return { hostedVerificationVisible: false };
}
throw new Error(`unexpected message type ${message.type}`);
},
setState: async (payload) => {
events.push({ type: 'set-state', payload });
},
sleepWithStop: async (ms) => {
events.push({ type: 'sleep', ms });
},
waitForTabCompleteUntilStopped: async () => {
events.push({ type: 'tab-complete' });
},
});
await executor.executePayPalHostedOpenAiCheckout({
plusCheckoutTabId: 55,
plusPaymentMethod: 'paypal-hosted',
hostedCheckoutPhoneNumber: '2125550000',
plusHostedCheckoutOauthDelaySeconds: 0,
});
const profileState = events.find((event) => event.type === 'set-state' && event.payload.plusHostedCheckoutGuestProfile)?.payload || {};
assert.equal(profileState.plusHostedCheckoutGuestProfile.phone, '4155551234');
assert.equal(profileState.plusHostedCheckoutPhoneDigits, '4155551234');
assert.equal(
events.find((event) => event.type === 'tab-message' && event.message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP')?.message?.payload?.address?.street,
'1 Main St'
);
assert.deepStrictEqual(events.find((event) => event.type === 'complete'), {
type: 'complete',
step: 'paypal-hosted-openai-checkout',
payload: {
plusCheckoutUrl: 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted',
plusCheckoutSource: 'paypal-hosted',
plusReturnUrl: 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted',
plusHostedCheckoutCompleted: true,
},
});
});
test('PayPal hosted email node completes when Next navigation drops the content response', async () => {
const events = [];
let currentUrl = 'https://www.paypal.com/checkoutweb/pay?token=EC-test';
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async (message, level = 'info', options = {}) => events.push({ type: 'log', message, level, options }),
chrome: {
tabs: {
get: async (tabId) => ({ id: tabId, url: currentUrl, status: 'complete' }),
},
},
completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => events.push({ type: 'ready', source, tabId, options }),
getState: async () => ({
hostedCheckoutPhoneNumber: '(415) 555-1234',
}),
registerTab: async (source, tabId) => events.push({ type: 'register', source, tabId }),
sendTabMessageUntilStopped: async (tabId, source, message) => {
events.push({ type: 'tab-message', tabId, source, message });
if (message.type === 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP') {
currentUrl = 'https://www.paypal.com/checkoutweb/signup?ba_token=BA-test&token=EC-test';
return new Promise(() => {});
}
if (message.type === 'PAYPAL_HOSTED_GET_STATE') {
return {
hostedStage: currentUrl.includes('/signup')
? 'guest_checkout'
: 'pay_login',
};
}
throw new Error(`unexpected message type ${message.type}`);
},
setState: async (payload) => events.push({ type: 'set-state', payload }),
sleepWithStop: async (ms) => events.push({ type: 'sleep', ms }),
waitForTabCompleteUntilStopped: async () => events.push({ type: 'tab-complete' }),
});
await executor.executePayPalHostedEmail({
plusCheckoutTabId: 85661333,
plusHostedCheckoutGuestProfile: {
email: 'guest@example.com',
phone: '4155551234',
address: { street: '1 Main St', city: 'New York', state: 'New York', zip: '10001' },
},
});
assert.equal(currentUrl.includes('/signup'), true);
assert.equal(
events.some((event) => event.type === 'complete' && event.step === 'paypal-hosted-email'),
true
);
assert.equal(
events.some((event) => event.type === 'log' && /已检测到 PayPal 进入后续页面(guest_checkout/.test(event.message)),
true
);
assert.equal(
events.some((event) => event.type === 'tab-message' && event.message.type === 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP'),
true
);
});
test('Plus checkout content routes billing operations through the operation delay gate', async () => {
const { checkoutEvents, send } = createCheckoutContentHarness();
@@ -123,6 +123,7 @@ return {
plusAccountAccessStrategy: 'oauth',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
accountContributionEnabled: false,
},
});
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [88] });
+63 -2
View File
@@ -113,7 +113,7 @@ return {
assert.deepEqual(api.getStepIds(), [7]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', plusAccountAccessStrategy: 'oauth', signupMethod: 'email', phoneSignupReloginAfterBindEmailEnabled: false },
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', plusAccountAccessStrategy: 'oauth', signupMethod: 'email', phoneSignupReloginAfterBindEmailEnabled: false, accountContributionEnabled: false },
});
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [7] });
});
@@ -188,6 +188,67 @@ return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount };
assert.equal(api.rowPayPalAccount.style.display, '');
});
test('sidepanel Plus UI separates PayPal account mode from PayPal no-card binding mode', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('getRequestedPlusAccountAccessStrategy'),
extractFunction('normalizeGpcHelperPhoneModeValue'),
extractFunction('getGpcHelperAutoModeEnabled'),
extractFunction('normalizeGpcAutoModePermissionValue'),
extractFunction('getGpcAutoModePermissionFromPayload'),
extractFunction('shouldPreserveSelectedGpcAutoMode'),
extractFunction('hasGpcAutoModePermissionField'),
extractFunction('isGpcAutoModePermissionDenied'),
extractFunction('normalizeGpcOtpChannelValue'),
extractFunction('updatePlusModeUI'),
].join('\n');
const api = new Function(`
let latestState = { plusPaymentMethod: 'paypal-hosted' };
let currentPlusPaymentMethod = 'paypal-hosted';
let currentPlusAccountAccessStrategy = 'oauth';
const inputPlusModeEnabled = { checked: true };
const selectPlusPaymentMethod = { value: 'paypal-hosted', style: { display: 'none' } };
const GPC_HELPER_PHONE_MODE_AUTO = 'auto';
const GPC_HELPER_PHONE_MODE_MANUAL = 'manual';
const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth';
const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';
const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session';
const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH;
const plusPaymentMethodCaption = { textContent: '' };
const rowPayPalAccount = { style: { display: '' } };
const rowHostedCheckoutVerificationUrl = { style: { display: 'none' } };
const rowHostedCheckoutPhone = { style: { display: 'none' } };
const rowPlusHostedCheckoutOauthDelay = { style: { display: 'none' } };
${bundle}
return {
updatePlusModeUI,
selectPlusPaymentMethod,
rowPayPalAccount,
plusPaymentMethodCaption,
rows: { rowHostedCheckoutVerificationUrl, rowHostedCheckoutPhone, rowPlusHostedCheckoutOauthDelay },
};
`)();
api.updatePlusModeUI();
assert.equal(api.rowPayPalAccount.style.display, 'none');
assert.equal(api.rows.rowHostedCheckoutVerificationUrl.style.display, '');
assert.equal(api.rows.rowHostedCheckoutPhone.style.display, '');
assert.equal(api.rows.rowPlusHostedCheckoutOauthDelay.style.display, '');
assert.match(api.plusPaymentMethodCaption.textContent, /无卡直绑/);
api.selectPlusPaymentMethod.value = 'paypal';
api.updatePlusModeUI();
assert.equal(api.rowPayPalAccount.style.display, '');
assert.equal(api.rows.rowHostedCheckoutVerificationUrl.style.display, 'none');
assert.equal(api.rows.rowHostedCheckoutPhone.style.display, 'none');
assert.equal(api.rows.rowPlusHostedCheckoutOauthDelay.style.display, 'none');
});
test('sidepanel Plus UI can hide Plus controls when the shared flow capability registry disables them', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
@@ -303,7 +364,7 @@ return {
assert.deepEqual(api.getStepIds(), [13]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', plusAccountAccessStrategy: 'oauth', signupMethod: 'email', phoneSignupReloginAfterBindEmailEnabled: false },
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', plusAccountAccessStrategy: 'oauth', signupMethod: 'email', phoneSignupReloginAfterBindEmailEnabled: false, accountContributionEnabled: false },
});
});
+49
View File
@@ -14,6 +14,7 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
phoneSignupReloginAfterBindEmailEnabled: true,
});
const plusSteps = api.getSteps({ plusModeEnabled: true });
const hostedSteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' });
const plusPhoneSteps = api.getSteps({ plusModeEnabled: true, signupMethod: 'phone' });
const plusPhoneReloginSteps = api.getSteps({
plusModeEnabled: true,
@@ -211,6 +212,34 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
assert.equal(plusSteps[5].title, '创建 Plus Checkout');
assert.equal(plusSteps[7].title, 'PayPal 登录与授权');
assert.deepStrictEqual(
hostedSteps.map((step) => step.key),
[
'open-chatgpt',
'submit-signup-email',
'fill-password',
'fetch-signup-code',
'fill-profile',
'plus-checkout-create',
'paypal-hosted-email',
'paypal-hosted-card',
'paypal-hosted-create-account',
'paypal-hosted-review',
'oauth-login',
'fetch-login-code',
'confirm-oauth',
'platform-verify',
]
);
assert.equal(hostedSteps.some((step) => step.key === 'plus-checkout-billing'), false);
assert.equal(hostedSteps.some((step) => step.key === 'paypal-approve'), false);
assert.equal(hostedSteps.some((step) => step.key === 'plus-checkout-return'), false);
assert.equal(hostedSteps.some((step) => step.key === 'paypal-hosted-openai-checkout'), false);
assert.equal(hostedSteps.some((step) => step.key === 'paypal-hosted-verification'), false);
assert.equal(hostedSteps.find((step) => step.key === 'paypal-hosted-card')?.title, '无卡直绑填写 PayPal 资料');
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]);
assert.equal(api.getLastStepId({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), 14);
assert.deepStrictEqual(
goPaySteps.map((step) => step.key),
[
@@ -279,6 +308,16 @@ test('Plus session strategy swaps the OAuth tail for a single SUB2API import nod
previousNodeId: 'plus-checkout-return',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
},
{
label: 'paypal-hosted',
options: {
plusModeEnabled: true,
plusPaymentMethod: 'paypal-hosted',
plusAccountAccessStrategy: 'sub2api_codex_session',
},
previousNodeId: 'paypal-hosted-review',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
},
{
label: 'gopay',
options: {
@@ -360,6 +399,16 @@ test('Plus session strategy swaps the OAuth tail for a single CPA import node',
previousNodeId: 'plus-checkout-return',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
},
{
label: 'paypal-hosted',
options: {
plusModeEnabled: true,
plusPaymentMethod: 'paypal-hosted',
plusAccountAccessStrategy: 'cpa_codex_session',
},
previousNodeId: 'paypal-hosted-review',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
},
{
label: 'gopay',
options: {