Integrate Plus hosted session binding

This commit is contained in:
QLHazyCoder
2026-05-20 07:38:49 +08:00
parent a70af6da40
commit 3aa9b136d3
20 changed files with 850 additions and 148 deletions
@@ -55,6 +55,8 @@ function extractFunction(name) {
function createHarness() {
return new Function(`
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const SIGNUP_METHOD_EMAIL = 'email';
const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';
const workflowEngine = null;
const self = {
MultiPageStepDefinitions: {
@@ -120,13 +122,10 @@ function isPlusModeState(state = {}) {
return Boolean(state?.plusModeEnabled);
}
function resolveCurrentFlowCapabilities(state = {}, options = {}) {
const normalizedPanelMode = String(options.panelMode || '').trim().toLowerCase();
const requestedStrategy = normalizePlusAccountAccessStrategy(state.plusAccountAccessStrategy);
const effectiveStrategy = normalizedPanelMode === 'sub2api'
? (requestedStrategy === 'sub2api_codex_session' ? 'sub2api_codex_session' : 'oauth')
: (normalizedPanelMode === 'cpa'
? (requestedStrategy === 'cpa_codex_session' ? 'cpa_codex_session' : 'oauth')
: 'oauth');
const effectiveStrategy = state.accountContributionEnabled && state.plusModeEnabled
? 'sub2api_codex_session'
: requestedStrategy;
return {
effectivePanelMode: options.panelMode,
effectivePlusAccountAccessStrategy: effectiveStrategy,
@@ -153,12 +152,12 @@ return {
`)();
}
test('background step resolution keeps SUB2API session tail only when the effective Plus target supports it', () => {
test('background step resolution keeps SUB2API session tail independent from the current panel mode', () => {
const api = createHarness();
const state = {
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'sub2api',
panelMode: 'cpa',
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
@@ -205,7 +204,7 @@ test('background step resolution keeps CPA session tail when the effective Plus
]);
});
test('background step resolution falls back to OAuth tail when the requested session strategy is not effective for the current panel mode', () => {
test('background step resolution keeps SUB2API session tail even when the current panel mode is CPA', () => {
const api = createHarness();
const state = {
activeFlowId: 'openai',
@@ -220,12 +219,34 @@ test('background step resolution falls back to OAuth tail when the requested ses
const resolvedState = api.buildResolvedStepDefinitionState(state);
const nodeIds = api.getNodeIdsForState(state);
assert.equal(resolvedState.plusAccountAccessStrategy, 'oauth');
assert.equal(nodeIds.includes('sub2api-session-import'), false);
assert.deepStrictEqual(nodeIds.slice(-4), [
'oauth-login',
'fetch-login-code',
'confirm-oauth',
'platform-verify',
assert.equal(resolvedState.plusAccountAccessStrategy, 'sub2api_codex_session');
assert.deepStrictEqual(nodeIds, [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'paypal-approve',
'plus-checkout-return',
'sub2api-session-import',
]);
});
test('background contribution Plus mode forces SUB2API session import over a CPA session request', () => {
const api = createHarness();
const state = {
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'cpa',
plusModeEnabled: true,
accountContributionEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'cpa_codex_session',
signupMethod: 'email',
};
const resolvedState = api.buildResolvedStepDefinitionState(state);
const nodeIds = api.getNodeIdsForState(state);
assert.equal(resolvedState.plusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(nodeIds.includes('cpa-session-import'), false);
assert.equal(nodeIds.at(-1), 'sub2api-session-import');
});
+15 -53
View File
@@ -453,7 +453,7 @@ test('SAVE_SETTING rebuilds Plus node statuses when the account access strategy
});
});
test('SAVE_SETTING rebuilds Plus node statuses when panel mode forces the effective strategy back to OAuth', async () => {
test('SAVE_SETTING rebuilds Plus node statuses without forcing the Plus strategy back to OAuth', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
@@ -491,8 +491,7 @@ test('SAVE_SETTING rebuilds Plus node statuses when panel mode forces the effect
: {},
broadcastDataUpdate: (payload) => broadcasts.push(payload),
getNodeIdsForState: (nextState = {}) => (
String(nextState.panelMode || '').trim() === 'sub2api'
&& String(nextState.plusAccountAccessStrategy || '').trim() === 'sub2api_codex_session'
String(nextState.plusAccountAccessStrategy || '').trim() === 'sub2api_codex_session'
? [
'open-chatgpt',
'plus-checkout-create',
@@ -532,61 +531,24 @@ test('SAVE_SETTING rebuilds Plus node statuses when panel mode forces the effect
assert.equal(response.ok, true);
assert.equal(state.panelMode, 'cpa');
assert.equal(state.plusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(state.currentNodeId, '');
assert.equal(state.oauthUrl, null);
assert.equal(state.localhostUrl, null);
assert.equal(state.sub2apiSessionId, null);
assert.equal(state.plusManualConfirmationPending, false);
assert.equal(state.plusManualConfirmationMessage, '');
assert.equal(state.currentNodeId, 'sub2api-session-import');
assert.equal(state.oauthUrl, 'https://oauth.example/current');
assert.equal(state.localhostUrl, 'http://localhost:38080/callback');
assert.equal(state.sub2apiSessionId, 'sub-session');
assert.equal(state.plusManualConfirmationPending, true);
assert.equal(state.plusManualConfirmationMessage, '完成后继续导入当前 ChatGPT 会话到 SUB2API。');
assert.deepStrictEqual(state.nodeStatuses, {
'open-chatgpt': 'pending',
'plus-checkout-create': 'pending',
'plus-checkout-billing': 'pending',
'paypal-approve': 'pending',
'plus-checkout-return': 'pending',
'oauth-login': 'pending',
'fetch-login-code': 'pending',
'post-login-phone-verification': 'pending',
'confirm-oauth': 'pending',
'platform-verify': 'pending',
'open-chatgpt': 'completed',
'plus-checkout-create': 'completed',
'plus-checkout-billing': 'completed',
'paypal-approve': 'completed',
'plus-checkout-return': 'completed',
'sub2api-session-import': 'running',
});
assert.equal(Object.prototype.hasOwnProperty.call(state.nodeStatuses, 'sub2api-session-import'), false);
assert.equal(Object.prototype.hasOwnProperty.call(state.nodeStatuses, 'oauth-login'), false);
assert.deepStrictEqual(broadcasts.at(-1), {
panelMode: 'cpa',
signupMethod: 'email',
oauthUrl: null,
localhostUrl: null,
oauthFlowDeadlineAt: null,
oauthFlowDeadlineSourceUrl: null,
cpaOAuthState: null,
cpaManagementOrigin: null,
sub2apiSessionId: null,
sub2apiOAuthState: null,
sub2apiGroupId: null,
sub2apiGroupIds: [],
sub2apiDraftName: null,
sub2apiProxyId: null,
codex2apiSessionId: null,
codex2apiOAuthState: null,
plusManualConfirmationPending: false,
plusManualConfirmationRequestId: '',
plusManualConfirmationStep: 0,
plusManualConfirmationMethod: '',
plusManualConfirmationTitle: '',
plusManualConfirmationMessage: '',
nodeStatuses: {
'open-chatgpt': 'pending',
'plus-checkout-create': 'pending',
'plus-checkout-billing': 'pending',
'paypal-approve': 'pending',
'plus-checkout-return': 'pending',
'oauth-login': 'pending',
'fetch-login-code': 'pending',
'post-login-phone-verification': 'pending',
'confirm-oauth': 'pending',
'platform-verify': 'pending',
},
currentNodeId: '',
});
});
+31 -6
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 session strategies independent from the current OpenAI target', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
@@ -254,13 +254,38 @@ test('flow capability registry forces OAuth when the current target only support
openaiIntegrationTargetId: 'codex2api',
signupMethod: 'email',
plusModeEnabled: true,
plusAccountAccessStrategy: 'sub2api_codex_session',
},
});
assert.deepEqual(
capabilityState.availablePlusAccountAccessStrategies,
['oauth', 'cpa_codex_session', 'sub2api_codex_session']
);
assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(capabilityState.canEditPlusAccountAccessStrategy, true);
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'sub2api_codex_session');
});
test('flow capability registry forces SUB2API Plus session import during contribution mode', () => {
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, ['oauth']);
assert.deepEqual(capabilityState.availablePlusAccountAccessStrategies, ['sub2api_codex_session']);
assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'cpa_codex_session');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'oauth');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(capabilityState.canEditPlusAccountAccessStrategy, false);
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'oauth');
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'sub2api_codex_session');
});
@@ -0,0 +1,120 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.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;
}
}
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('background routes plus-checkout-create through a completion signal for hosted checkout', () => {
const backgroundSetStart = source.indexOf('const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([');
const signalSetStart = source.indexOf('const STEP_COMPLETION_SIGNAL_STEP_KEYS = new Set([');
const timeoutMapStart = source.indexOf('const STEP_COMPLETION_SIGNAL_TIMEOUTS_BY_STEP_KEY = new Map([');
const backgroundSetSource = source.slice(backgroundSetStart, signalSetStart);
const signalSetSource = source.slice(signalSetStart, timeoutMapStart);
assert.doesNotMatch(backgroundSetSource, /'plus-checkout-create'/);
assert.match(signalSetSource, /'plus-checkout-create'/);
});
test('background gives PayPal session-import hosted checkout a long completion and idle window', () => {
const bundle = `
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 AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS = 120000;
const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 5 * 60 * 1000;
const HOSTED_CHECKOUT_SUCCESS_SIGNAL_TIMEOUT_MS = 30 * 60 * 1000;
const STEP_COMPLETION_SIGNAL_TIMEOUTS_BY_STEP_KEY = new Map([
['fill-profile', 150000],
['gopay-subscription-confirm', 1800000],
]);
function normalizePlusAccountAccessStrategy(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) return PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION;
if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) return PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION;
return PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH;
}
function getNodeExecutionKeyForState(nodeId, state = {}) {
return String(state?.nodes?.[nodeId]?.executeKey || nodeId || '').trim();
}
${extractFunction('isHostedCheckoutSuccessCompletionNode')}
${extractFunction('getNodeCompletionSignalTimeoutMs')}
${extractFunction('getAutoRunNodeIdleLogTimeoutMs')}
return { isHostedCheckoutSuccessCompletionNode, getNodeCompletionSignalTimeoutMs, getAutoRunNodeIdleLogTimeoutMs };
`;
const api = new Function(bundle)();
const hostedState = {
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
signupMethod: 'email',
plusAccountAccessStrategy: 'sub2api_codex_session',
};
assert.equal(api.isHostedCheckoutSuccessCompletionNode('plus-checkout-create', hostedState), true);
assert.equal(api.getNodeCompletionSignalTimeoutMs('plus-checkout-create', hostedState), 30 * 60 * 1000);
assert.equal(api.getAutoRunNodeIdleLogTimeoutMs('plus-checkout-create', hostedState), 30 * 60 * 1000);
assert.equal(api.isHostedCheckoutSuccessCompletionNode('plus-checkout-create', {
...hostedState,
accountContributionEnabled: true,
plusAccountAccessStrategy: 'oauth',
}), true);
assert.equal(api.isHostedCheckoutSuccessCompletionNode('plus-checkout-create', { ...hostedState, plusAccountAccessStrategy: 'oauth' }), false);
assert.equal(api.isHostedCheckoutSuccessCompletionNode('plus-checkout-create', { ...hostedState, signupMethod: 'phone' }), false);
assert.equal(api.isHostedCheckoutSuccessCompletionNode('plus-checkout-create', {
...hostedState,
accountContributionEnabled: true,
signupMethod: 'phone',
}), false);
assert.equal(api.isHostedCheckoutSuccessCompletionNode('plus-checkout-create', { ...hostedState, plusPaymentMethod: 'gopay' }), false);
assert.equal(api.getNodeCompletionSignalTimeoutMs('plus-checkout-create', { ...hostedState, plusAccountAccessStrategy: 'oauth' }), 120000);
assert.equal(api.getAutoRunNodeIdleLogTimeoutMs('plus-checkout-create', { ...hostedState, plusAccountAccessStrategy: 'oauth' }), 5 * 60 * 1000);
});
test('background imports and registers the hosted checkout success manager', () => {
assert.match(source, /importScripts\([\s\S]*'background\/plus-hosted-checkout-success\.js'/);
assert.match(source, /createPlusHostedCheckoutSuccessManager\(\{/);
assert.match(source, /chrome\.tabs\.onUpdated\.addListener\(\(tabId, changeInfo, tab\) => \{[\s\S]*plusHostedCheckoutSuccessManager\?\.handleTabUpdated/);
});
+9 -6
View File
@@ -115,13 +115,14 @@ return {
`)();
}
test('sidepanel keeps requested Plus account strategy while OAuth-only targets force the effective value', () => {
test('sidepanel keeps requested SUB2API session strategy independent from the selected target', () => {
const api = buildHarness(
`{
canShowPlusSettings: true,
runtimeLocks: { plusModeEnabled: true },
canEditPlusAccountAccessStrategy: false,
effectivePlusAccountAccessStrategy: 'oauth',
canEditPlusAccountAccessStrategy: true,
availablePlusAccountAccessStrategies: ['oauth', 'cpa_codex_session', 'sub2api_codex_session'],
effectivePlusAccountAccessStrategy: 'sub2api_codex_session',
}`,
`{
activeFlowId: 'openai',
@@ -134,11 +135,11 @@ test('sidepanel keeps requested Plus account strategy while OAuth-only targets f
api.updatePlusModeUI();
assert.equal(api.rowPlusAccountAccessStrategy.style.display, '');
assert.equal(api.selectPlusAccountAccessStrategy.disabled, true);
assert.equal(api.selectPlusAccountAccessStrategy.disabled, false);
assert.equal(api.selectPlusAccountAccessStrategy.dataset.requestedValue, 'sub2api_codex_session');
assert.equal(api.selectPlusAccountAccessStrategy.value, 'oauth');
assert.equal(api.selectPlusAccountAccessStrategy.value, 'sub2api_codex_session');
assert.equal(api.getRequestedPlusAccountAccessStrategy(), 'sub2api_codex_session');
assert.match(api.plusAccountAccessStrategyCaption.textContent, /OAuth/);
assert.match(api.plusAccountAccessStrategyCaption.textContent, /SUB2API/);
});
test('sidepanel enables SUB2API session strategy selection when the current Plus target supports it', () => {
@@ -283,6 +284,7 @@ return {
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
accountContributionEnabled: false,
},
},
{
@@ -294,6 +296,7 @@ return {
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
accountContributionEnabled: false,
},
},
]);
+51 -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}`);
@@ -124,9 +124,58 @@ test('CREATE_PLUS_CHECKOUT keeps PayPal on DE/EUR and openai_ie merchant path by
assert.equal(checkoutCall.options.headers.Authorization, 'Bearer test-access-token');
const payload = JSON.parse(checkoutCall.options.body);
assert.equal(payload.plan_name, 'chatgptplusplan');
assert.equal(payload.checkout_ui_mode, 'custom');
assert.deepEqual(payload.billing_details, { country: 'DE', currency: 'EUR' });
});
test('CREATE_PLUS_CHECKOUT ignores hosted PayPal URLs outside hosted mode', async () => {
const harness = createPlusCheckoutMessageHarness({
checkoutResponse: {
checkout_session_id: 'cs_paypal_custom',
nested: {
hosted: 'https://pay.openai.com/c/pay/hosted_cs_paypal_custom',
},
},
});
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_custom');
assert.equal(result.hostedCheckoutUrl, 'https://pay.openai.com/c/pay/hosted_cs_paypal_custom');
assert.equal(result.fallbackCheckoutUrl, 'https://chatgpt.com/checkout/openai_ie/cs_paypal_custom');
});
test('CREATE_PLUS_CHECKOUT prefers the hosted PayPal checkout URL only in hosted mode', async () => {
const harness = createPlusCheckoutMessageHarness({
checkoutResponse: {
checkout_session_id: 'cs_paypal_hosted',
nested: {
hosted: 'https://pay.openai.com/c/pay/hosted_cs_paypal',
},
},
});
const result = await harness.send({
type: 'CREATE_PLUS_CHECKOUT',
source: 'test',
payload: { hostedCheckoutMode: true },
});
assert.equal(result.ok, true);
assert.equal(result.checkoutUrl, 'https://pay.openai.com/c/pay/hosted_cs_paypal');
assert.equal(result.hostedCheckoutUrl, 'https://pay.openai.com/c/pay/hosted_cs_paypal');
assert.equal(result.fallbackCheckoutUrl, 'https://chatgpt.com/checkout/openai_ie/cs_paypal_hosted');
const checkoutCall = harness.fetchCalls.find((call) => call.url === 'https://chatgpt.com/backend-api/payments/checkout');
const payload = JSON.parse(checkoutCall.options.body);
assert.equal(payload.checkout_ui_mode, 'hosted');
});
test('CREATE_PLUS_CHECKOUT uses ID/IDR and openai_llc merchant path for GoPay', async () => {
const harness = createPlusCheckoutMessageHarness({ checkoutSessionId: 'cs_gopay' });
+96
View File
@@ -257,6 +257,102 @@ test('Plus checkout create does not wait 20 seconds after opening checkout page'
assert.equal(events.some((event) => event.type === 'sleep' && event.ms === 20000), false);
});
test('PayPal session-import checkout waits for the ChatGPT payment success signal', async () => {
const events = [];
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: 43 };
},
update: async (tabId, payload) => events.push({ type: 'tab-update', tabId, payload }),
},
},
completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async () => events.push({ type: 'ready' }),
registerTab: async (source, tabId) => events.push({ type: 'register', source, tabId }),
sendTabMessageUntilStopped: async (tabId, source, message) => {
events.push({ type: 'tab-message', tabId, source, message });
return {
checkoutUrl: 'https://pay.openai.com/c/pay/hosted_session',
country: 'US',
currency: 'USD',
};
},
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({
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
});
assert.equal(events.some((event) => event.type === 'complete'), false);
assert.deepEqual(
events.find((event) => event.type === 'tab-message')?.message?.payload,
{ paymentMethod: 'paypal', hostedCheckoutMode: true }
);
assert.deepEqual(
events.find((event) => event.type === 'tab-update'),
{ type: 'tab-update', tabId: 43, payload: { url: 'https://pay.openai.com/c/pay/hosted_session', active: true } }
);
const statePayload = events.find((event) => event.type === 'set-state')?.payload || {};
assert.equal(statePayload.plusCheckoutTabId, 43);
assert.equal(statePayload.plusCheckoutUrl, 'https://pay.openai.com/c/pay/hosted_session');
assert.equal(statePayload.plusHostedCheckoutCompletionPending, true);
assert.equal(statePayload.plusHostedCheckoutCompleted, false);
assert.equal(statePayload.plusReturnUrl, '');
assert.equal(events.some((event) => event.type === 'log' && /waiting for the ChatGPT payment success page/.test(event.message)), true);
});
test('Contribution Plus checkout uses hosted success waiting even when the requested strategy is OAuth', async () => {
const events = [];
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async () => {},
chrome: {
tabs: {
create: async () => ({ id: 44 }),
update: async (tabId, payload) => events.push({ type: 'tab-update', tabId, payload }),
},
},
completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async () => {},
registerTab: async () => {},
sendTabMessageUntilStopped: async (tabId, source, message) => {
events.push({ type: 'tab-message', tabId, source, message });
return {
checkoutUrl: 'https://pay.openai.com/c/pay/contribution_hosted_session',
country: 'US',
currency: 'USD',
};
},
setState: async (payload) => events.push({ type: 'set-state', payload }),
sleepWithStop: async () => {},
waitForTabCompleteUntilStopped: async () => {},
});
await executor.executePlusCheckoutCreate({
plusModeEnabled: true,
accountContributionEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'oauth',
signupMethod: 'email',
});
assert.deepEqual(
events.find((event) => event.type === 'tab-message')?.message?.payload,
{ paymentMethod: 'paypal', hostedCheckoutMode: true }
);
assert.equal(events.some((event) => event.type === 'complete'), false);
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.plusHostedCheckoutCompletionPending, true);
});
test('GoPay plus checkout create forwards gopay payment method to the checkout content script', async () => {
const events = [];
const executor = api.createPlusCheckoutCreateExecutor({
+135
View File
@@ -0,0 +1,135 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/plus-hosted-checkout-success.js', 'utf8');
function loadApi() {
const scope = {};
return new Function('self', `${source}; return self.MultiPagePlusHostedCheckoutSuccess;`)(scope);
}
function createState(overrides = {}) {
return {
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusHostedCheckoutCompletionPending: true,
plusCheckoutTabId: 77,
nodeStatuses: {
'plus-checkout-create': 'running',
},
...overrides,
};
}
test('hosted checkout success manager completes plus-checkout-create on the tracked success tab', async () => {
const api = loadApi();
const events = [];
let state = createState();
const manager = api.createPlusHostedCheckoutSuccessManager({
addLog: async (message, level, options) => events.push({ type: 'log', message, level, options }),
completeNodeFromBackground: async (nodeId, payload) => events.push({ type: 'complete', nodeId, payload }),
getState: async () => state,
setState: async (payload) => {
events.push({ type: 'set-state', payload });
state = { ...state, ...payload };
},
});
const successUrl = 'https://chatgpt.com/payments/success?session_id=cs_test';
const result = await manager.handleTabUpdated(77, { status: 'complete' }, { url: successUrl });
assert.deepEqual(result, {
completed: true,
plusReturnUrl: successUrl,
});
assert.deepEqual(events.find((event) => event.type === 'complete'), {
type: 'complete',
nodeId: 'plus-checkout-create',
payload: {
plusReturnUrl: successUrl,
plusHostedCheckoutCompleted: true,
},
});
assert.equal(state.plusHostedCheckoutCompletionPending, false);
assert.equal(state.plusHostedCheckoutCompleted, true);
assert.equal(state.plusReturnUrl, successUrl);
assert.equal(events.find((event) => event.type === 'log')?.options?.nodeId, 'plus-checkout-create');
});
test('hosted checkout success manager ignores unrelated updates and inactive states', async () => {
const api = loadApi();
const events = [];
let state = createState();
const manager = api.createPlusHostedCheckoutSuccessManager({
completeNodeFromBackground: async () => events.push('complete'),
getState: async () => state,
setState: async () => events.push('set-state'),
});
assert.equal(api.isPaymentSuccessUrl('https://chatgpt.com/payments/success'), true);
assert.equal(api.isPaymentSuccessUrl('https://example.com/payments/success'), false);
assert.equal(await manager.handleTabUpdated(77, { status: 'loading' }, { url: 'https://chatgpt.com/payments/success' }), null);
assert.equal(await manager.handleTabUpdated(77, { status: 'complete' }, { url: 'https://chatgpt.com/' }), null);
assert.equal(await manager.handleTabUpdated(88, { status: 'complete' }, { url: 'https://chatgpt.com/payments/success' }), null);
state = createState({ plusHostedCheckoutCompletionPending: false });
assert.equal(await manager.handleTabUpdated(77, { status: 'complete' }, { url: 'https://chatgpt.com/payments/success' }), null);
state = createState({ plusHostedCheckoutCompletionPending: true, nodeStatuses: { 'plus-checkout-create': 'completed' } });
assert.equal(await manager.handleTabUpdated(77, { status: 'complete' }, { url: 'https://chatgpt.com/payments/success' }), null);
assert.deepEqual(events, []);
});
test('hosted checkout success manager deduplicates concurrent success events for one tab', async () => {
const api = loadApi();
const events = [];
const state = createState();
let releaseComplete;
const completeGate = new Promise((resolve) => {
releaseComplete = resolve;
});
const manager = api.createPlusHostedCheckoutSuccessManager({
addLog: async () => {},
completeNodeFromBackground: async (nodeId) => {
events.push({ type: 'complete', nodeId });
await completeGate;
},
getState: async () => state,
setState: async (payload) => events.push({ type: 'set-state', payload }),
});
const first = manager.handleTabUpdated(77, { status: 'complete' }, { url: 'https://chatgpt.com/payments/success' });
const second = manager.handleTabUpdated(77, { status: 'complete' }, { url: 'https://chatgpt.com/payments/success' });
await Promise.resolve();
releaseComplete();
const results = await Promise.all([first, second]);
assert.equal(results.filter(Boolean).length, 1);
assert.equal(events.filter((event) => event.type === 'complete').length, 1);
});
test('hosted checkout success manager fails the checkout node when continuation throws', async () => {
const api = loadApi();
const events = [];
const manager = api.createPlusHostedCheckoutSuccessManager({
addLog: async (message, level) => events.push({ type: 'log', message, level }),
completeNodeFromBackground: async () => {
throw new Error('boom');
},
failNodeFromBackground: async (nodeId, message) => events.push({ type: 'fail', nodeId, message }),
getState: async () => createState(),
setState: async () => {},
});
const result = await manager.handleTabUpdated(77, { status: 'complete' }, { url: 'https://chatgpt.com/payments/success' });
assert.equal(result.failed, true);
assert.equal(result.message, 'boom');
assert.deepEqual(events.find((event) => event.type === 'fail'), {
type: 'fail',
nodeId: 'plus-checkout-create',
message: 'boom',
});
assert.equal(events.some((event) => event.type === 'log' && event.level === 'error'), true);
});
@@ -123,6 +123,7 @@ return {
plusAccountAccessStrategy: 'oauth',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
accountContributionEnabled: false,
},
});
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [88] });
+2 -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] });
});
@@ -303,7 +303,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 },
});
});
+4 -4
View File
@@ -276,8 +276,8 @@ test('Plus session strategy swaps the OAuth tail for a single SUB2API import nod
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
},
previousNodeId: 'plus-checkout-return',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
previousNodeId: 'plus-checkout-create',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7],
},
{
label: 'gopay',
@@ -357,8 +357,8 @@ test('Plus session strategy swaps the OAuth tail for a single CPA import node',
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'cpa_codex_session',
},
previousNodeId: 'plus-checkout-return',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
previousNodeId: 'plus-checkout-create',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7],
},
{
label: 'gopay',