feat: add CPA plus session import strategy

This commit is contained in:
QLHazyCoder
2026-05-19 16:56:08 +08:00
parent 85a6cc4157
commit 40b9cd397d
24 changed files with 1936 additions and 55 deletions
@@ -55,6 +55,7 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
extractFunction('normalizeVerificationResendCount'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('normalizeGpcHelperPhoneMode'),
extractFunction('normalizePhoneSmsProvider'),
extractFunction('normalizePhoneSmsProviderOrder'),
@@ -125,6 +126,10 @@ const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
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 DEFAULT_FIVE_SIM_PRODUCT = 'openai';
const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot';
const FIVE_SIM_COUNTRY_ID = 'vietnam';
@@ -228,6 +233,7 @@ return {
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');
assert.equal(api.normalizePersistentSettingValue('plusAccountAccessStrategy', 'cpa_codex_session'), 'cpa_codex_session');
assert.equal(api.normalizePersistentSettingValue('plusAccountAccessStrategy', 'unknown'), 'oauth');
assert.equal(
api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gpc.qlhazycoder.top/api/checkout/start '),
+2 -1
View File
@@ -48,13 +48,14 @@ function extractFunction(name) {
return source.slice(start, end);
}
test('background auth chain set does not include SUB2API session import node', () => {
test('background auth chain set does not include Plus session import nodes', () => {
const authChainStart = source.indexOf('const AUTH_CHAIN_NODE_IDS = new Set([');
const authChainEnd = source.indexOf(']);', authChainStart);
const authChainBlock = source.slice(authChainStart, authChainEnd);
assert.ok(authChainStart >= 0, 'expected AUTH_CHAIN_NODE_IDS block to exist');
assert.doesNotMatch(authChainBlock, /sub2api-session-import/);
assert.doesNotMatch(authChainBlock, /cpa-session-import/);
});
const NODE_EXECUTE_COMPAT_HELPERS = `
+184
View File
@@ -0,0 +1,184 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function createJsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => payload,
};
}
function loadCpaApiModule() {
const source = fs.readFileSync('background/cpa-api.js', 'utf8');
return new Function('self', `${source}; return self.MultiPageBackgroundCpaApi;`)({});
}
function encodeBase64UrlJson(value) {
return Buffer.from(JSON.stringify(value)).toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function createJwtToken(payload = {}) {
return [
encodeBase64UrlJson({ alg: 'HS256', typ: 'JWT' }),
encodeBase64UrlJson(payload),
'signature',
].join('.');
}
test('cpa api imports current ChatGPT session through management auth-files endpoint', async () => {
const apiModule = loadCpaApiModule();
const logs = [];
const fetchCalls = [];
const expiresAt = '2026-05-20T12:34:56.000Z';
const accessToken = createJwtToken({
exp: Math.floor(Date.parse(expiresAt) / 1000),
email: 'jwt@example.com',
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_123',
chatgpt_plan_type: 'plus',
chatgpt_user_id: 'user_123',
},
'https://api.openai.com/profile': {
email: 'profile@example.com',
},
});
const api = apiModule.createCpaApi({
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
const body = options.body ? JSON.parse(options.body) : null;
fetchCalls.push({
path: parsed.pathname,
search: parsed.search,
method: options.method || 'POST',
headers: options.headers || {},
body,
});
if (parsed.pathname === '/v0/management/auth-files') {
return createJsonResponse({});
}
return createJsonResponse({ message: `unexpected path ${parsed.pathname}` }, 404);
},
});
const result = await api.importCurrentChatGptSession({
vpsUrl: 'https://cpa.example.com/management.html#/oauth',
vpsPassword: 'management-key',
session: {
accessToken,
expires: expiresAt,
user: {
email: 'flow@example.com',
},
account: {
id: 'acct_123',
planType: 'plus',
},
},
accessToken,
email: 'registration@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'identifier@example.com',
}, {
logLabel: '步骤 10',
logOptions: { step: 10, stepKey: 'cpa-session-import' },
});
const importCall = fetchCalls.find((call) => call.path === '/v0/management/auth-files');
assert.ok(importCall, 'expected CPA auth-files import call');
assert.equal(importCall.method, 'POST');
assert.equal(importCall.headers.Authorization, 'Bearer management-key');
assert.equal(importCall.headers['X-Management-Key'], 'management-key');
assert.equal(
decodeURIComponent(new URLSearchParams(importCall.search).get('name')),
'codex-flow@example.com-plus.json'
);
assert.equal(importCall.body.type, 'codex');
assert.equal(importCall.body.account_id, 'acct_123');
assert.equal(importCall.body.chatgpt_account_id, 'acct_123');
assert.equal(importCall.body.email, 'flow@example.com');
assert.equal(importCall.body.plan_type, 'plus');
assert.equal(importCall.body.chatgpt_plan_type, 'plus');
assert.equal(importCall.body.access_token, accessToken);
assert.equal(importCall.body.id_token_synthetic, true);
assert.match(String(importCall.body.id_token || ''), /\.synthetic$/);
assert.equal(importCall.body.expired, expiresAt);
assert.equal(Object.prototype.hasOwnProperty.call(importCall.body, 'refresh_token'), false);
assert.equal(result.verifiedStatus, 'CPA 会话导入完成:flow@example.com');
assert.equal(
logs.some((entry) => entry.level === 'warn' && /refresh_token/.test(entry.message)),
true
);
});
test('cpa api falls back to registration email when session has no readable email', () => {
const apiModule = loadCpaApiModule();
const accessToken = createJwtToken({
exp: Math.floor(Date.parse('2026-05-20T12:34:56.000Z') / 1000),
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_456',
chatgpt_plan_type: 'plus',
chatgpt_user_id: 'user_456',
},
});
const api = apiModule.createCpaApi();
const result = api.buildCpaSessionAuthJson({
session: {
accessToken,
user: {
name: 'Flow User',
},
},
accessToken,
email: 'registration@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'identifier@example.com',
});
assert.equal(result.email, 'registration@example.com');
assert.equal(result.authJson.email, 'registration@example.com');
assert.equal(result.fileName, 'codex-registration@example.com-plus.json');
});
test('cpa api preserves provided id_token and refresh_token when available', () => {
const apiModule = loadCpaApiModule();
const accessToken = createJwtToken({
exp: Math.floor(Date.parse('2026-05-20T12:34:56.000Z') / 1000),
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_789',
chatgpt_plan_type: 'plus',
chatgpt_user_id: 'user_789',
},
});
const api = apiModule.createCpaApi();
const result = api.buildCpaSessionAuthJson({
session: {
accessToken,
user: {
email: 'session@example.com',
},
},
accessToken,
id_token: 'provided-id-token',
refresh_token: 'refresh-token-1',
session_token: 'session-token-1',
});
assert.equal(result.authJson.id_token, 'provided-id-token');
assert.equal(Object.prototype.hasOwnProperty.call(result.authJson, 'id_token_synthetic'), false);
assert.equal(result.authJson.refresh_token, 'refresh-token-1');
assert.equal(result.authJson.session_token, 'session-token-1');
assert.equal(result.hasRefreshToken, true);
});
+247
View File
@@ -0,0 +1,247 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
function loadCpaSessionImportModule() {
const source = fs.readFileSync('background/steps/cpa-session-import.js', 'utf8');
return new Function('self', `${source}; return self.MultiPageBackgroundCpaSessionImport;`)({});
}
test('CPA session import step reads current ChatGPT session and completes node', async () => {
const moduleApi = loadCpaSessionImportModule();
const completed = [];
const logs = [];
const ensureCalls = [];
const sentMessages = [];
const importedPayloads = [];
const executor = moduleApi.createCpaSessionImportExecutor({
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
chrome: {
tabs: {
get: async (tabId) => ({
id: tabId,
url: 'https://chatgpt.com/?model=gpt-4o',
}),
update: async () => {},
},
},
completeNodeFromBackground: async (nodeId, payload) => {
completed.push({ nodeId, payload });
},
createCpaApi: () => ({
importCurrentChatGptSession: async (state, options) => {
importedPayloads.push({ state, options });
return {
verifiedStatus: 'CPA 会话导入完成:flow@example.com',
cpaImportedFileName: 'codex-flow@example.com-plus.json',
cpaImportedEmail: 'flow@example.com',
};
},
}),
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options = {}) => {
ensureCalls.push({ source, tabId, options });
},
getTabId: async () => null,
isTabAlive: async () => false,
registerTab: async () => {},
sendTabMessageUntilStopped: async (tabId, source, message) => {
sentMessages.push({ tabId, source, message });
return {
session: {
accessToken: 'session-access-token',
expires: '2026-05-20T12:34:56.000Z',
user: {
email: 'flow@example.com',
},
},
accessToken: 'session-access-token',
};
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabCompleteUntilStopped: async () => {},
});
await executor.executeCpaSessionImport({
nodeId: 'cpa-session-import',
visibleStep: 10,
plusCheckoutTabId: 91,
vpsUrl: 'https://cpa.example.com/management.html#/oauth',
vpsPassword: 'management-key',
});
assert.equal(ensureCalls.length, 1);
assert.equal(ensureCalls[0].source, 'plus-checkout');
assert.deepStrictEqual(ensureCalls[0].options.inject, [
'content/utils.js',
'content/operation-delay.js',
'content/plus-checkout.js',
]);
assert.deepStrictEqual(sentMessages, [{
tabId: 91,
source: 'plus-checkout',
message: {
type: 'PLUS_CHECKOUT_GET_STATE',
source: 'background',
payload: {
includeSession: true,
includeAccessToken: true,
},
},
}]);
assert.equal(importedPayloads.length, 1);
assert.equal(importedPayloads[0].state.accessToken, 'session-access-token');
assert.equal(importedPayloads[0].state.session.user.email, 'flow@example.com');
assert.equal(completed.length, 1);
assert.deepStrictEqual(completed[0], {
nodeId: 'cpa-session-import',
payload: {
verifiedStatus: 'CPA 会话导入完成:flow@example.com',
cpaImportedFileName: 'codex-flow@example.com-plus.json',
cpaImportedEmail: 'flow@example.com',
},
});
assert.equal(
logs.some((entry) => entry.stepKey === 'cpa-session-import' && /ChatGPT/.test(entry.message)),
true
);
});
test('CPA session import step falls back to an active ChatGPT tab when no checkout tab is tracked', async () => {
const moduleApi = loadCpaSessionImportModule();
const completed = [];
const importedPayloads = [];
const queryCalls = [];
const registerCalls = [];
const sessionTab = {
id: 77,
url: 'https://chatgpt.com/?model=gpt-4o',
active: true,
currentWindow: true,
lastAccessed: 1234,
};
const executor = moduleApi.createCpaSessionImportExecutor({
addLog: async () => {},
chrome: {
tabs: {
get: async (tabId) => ({
...sessionTab,
id: tabId,
}),
query: async (queryInfo = {}) => {
queryCalls.push(queryInfo);
if (queryInfo.active && queryInfo.currentWindow) {
return [sessionTab];
}
return [sessionTab];
},
update: async () => {},
},
},
completeNodeFromBackground: async (nodeId, payload) => {
completed.push({ nodeId, payload });
},
createCpaApi: () => ({
importCurrentChatGptSession: async (state) => {
importedPayloads.push(state);
return {
verifiedStatus: 'CPA 会话导入完成:fallback@example.com',
};
},
}),
ensureContentScriptReadyOnTabUntilStopped: async () => {},
getTabId: async () => null,
isTabAlive: async () => false,
registerTab: async (source, tabId) => {
registerCalls.push({ source, tabId });
},
sendTabMessageUntilStopped: async () => ({
session: {
accessToken: 'session-access-token',
user: {
email: 'fallback@example.com',
},
},
accessToken: 'session-access-token',
}),
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabCompleteUntilStopped: async () => {},
});
await executor.executeCpaSessionImport({
nodeId: 'cpa-session-import',
visibleStep: 10,
vpsUrl: 'https://cpa.example.com/management.html#/oauth',
vpsPassword: 'management-key',
});
assert.deepStrictEqual(queryCalls, [
{ active: true, currentWindow: true },
{},
]);
assert.deepStrictEqual(registerCalls, [{
source: 'plus-checkout',
tabId: 77,
}]);
assert.equal(importedPayloads.length, 1);
assert.equal(importedPayloads[0].session.user.email, 'fallback@example.com');
assert.equal(completed.length, 1);
});
test('CPA session import step reports missing readable session tab when tracked tabs are unusable', async () => {
const moduleApi = loadCpaSessionImportModule();
let sendCalled = false;
const executor = moduleApi.createCpaSessionImportExecutor({
addLog: async () => {},
chrome: {
tabs: {
get: async (tabId) => ({
id: tabId,
url: 'https://example.com/not-chatgpt',
}),
update: async () => {},
},
},
completeNodeFromBackground: async () => {},
createCpaApi: () => ({
importCurrentChatGptSession: async () => ({}),
}),
ensureContentScriptReadyOnTabUntilStopped: async () => {},
getTabId: async () => 91,
isTabAlive: async () => true,
sendTabMessageUntilStopped: async () => {
sendCalled = true;
return {};
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabCompleteUntilStopped: async () => {},
});
await assert.rejects(
() => executor.executeCpaSessionImport({
nodeId: 'cpa-session-import',
visibleStep: 10,
vpsUrl: 'https://cpa.example.com/management.html#/oauth',
vpsPassword: 'management-key',
}),
/未找到可读取 ChatGPT 会话的标签页/
);
assert.equal(sendCalled, false);
});
test('background wires CPA session import executor into the workflow runtime', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/cpa-api\.js/);
assert.match(source, /background\/steps\/cpa-session-import\.js/);
assert.match(source, /'cpa-session-import': \(state\) => cpaSessionImportExecutor\.executeCpaSessionImport\(state\)/);
assert.match(source, /'cpa-session-import',[\s\S]*'oauth-login'/);
});
@@ -59,14 +59,25 @@ const workflowEngine = null;
const self = {
MultiPageStepDefinitions: {
getSteps(options = {}) {
return String(options.plusAccountAccessStrategy || '').trim() === 'sub2api_codex_session'
? [
const strategy = String(options.plusAccountAccessStrategy || '').trim();
if (strategy === 'sub2api_codex_session') {
return [
{ id: 1, key: 'open-chatgpt' },
{ id: 2, key: 'plus-checkout-create' },
{ id: 3, key: 'plus-checkout-billing' },
{ id: 4, key: 'paypal-approve' },
{ id: 5, key: 'plus-checkout-return' },
{ id: 6, key: 'sub2api-session-import' },
];
}
return strategy === 'cpa_codex_session'
? [
{ id: 1, key: 'open-chatgpt' },
{ id: 2, key: 'plus-checkout-create' },
{ id: 3, key: 'plus-checkout-billing' },
{ id: 4, key: 'paypal-approve' },
{ id: 5, key: 'plus-checkout-return' },
{ id: 6, key: 'cpa-session-import' },
]
: [
{ id: 1, key: 'open-chatgpt' },
@@ -96,17 +107,26 @@ function normalizePlusPaymentMethod(value = '') {
return normalized === 'gopay' || normalized === 'gpc-helper' ? normalized : 'paypal';
}
function normalizePlusAccountAccessStrategy(value = '') {
return String(value || '').trim().toLowerCase() === 'sub2api_codex_session'
? 'sub2api_codex_session'
: 'oauth';
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'sub2api_codex_session') {
return 'sub2api_codex_session';
}
if (normalized === 'cpa_codex_session') {
return 'cpa_codex_session';
}
return 'oauth';
}
function isPlusModeState(state = {}) {
return Boolean(state?.plusModeEnabled);
}
function resolveCurrentFlowCapabilities(state = {}, options = {}) {
const effectiveStrategy = String(options.panelMode || '').trim().toLowerCase() === 'sub2api'
? normalizePlusAccountAccessStrategy(state.plusAccountAccessStrategy)
: 'oauth';
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');
return {
effectivePanelMode: options.panelMode,
effectivePlusAccountAccessStrategy: effectiveStrategy,
@@ -159,6 +179,32 @@ test('background step resolution keeps SUB2API session tail only when the effect
]);
});
test('background step resolution keeps CPA session tail when the effective Plus target supports it', () => {
const api = createHarness();
const state = {
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'cpa',
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'cpa_codex_session',
signupMethod: 'email',
};
const resolvedState = api.buildResolvedStepDefinitionState(state);
const nodeIds = api.getNodeIdsForState(state);
assert.equal(resolvedState.plusAccountAccessStrategy, 'cpa_codex_session');
assert.deepStrictEqual(nodeIds, [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'paypal-approve',
'plus-checkout-return',
'cpa-session-import',
]);
});
test('background step resolution falls back to OAuth tail when the requested session strategy is not effective for the current panel mode', () => {
const api = createHarness();
const state = {
@@ -173,3 +173,33 @@ test('message router appends success record when SUB2API session import is the f
assert.equal(appendCalls.length, 1);
assert.equal(appendCalls[0][0], 'success');
});
test('message router appends success record when CPA session import is the final Plus node', async () => {
const { appendCalls, router } = createRouterWithFinalNode({
finalNodeId: 'cpa-session-import',
nodeIds: [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'paypal-approve',
'plus-checkout-return',
'cpa-session-import',
],
nodeStepMap: {
'plus-checkout-create': 6,
'plus-checkout-billing': 7,
'paypal-approve': 8,
'plus-checkout-return': 9,
'cpa-session-import': 10,
},
});
await router.handleMessage({
type: 'NODE_COMPLETE',
nodeId: 'cpa-session-import',
payload: { nodeId: 'cpa-session-import' },
}, {});
assert.equal(appendCalls.length, 1);
assert.equal(appendCalls[0][0], 'success');
});
@@ -111,6 +111,10 @@ const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
const PERSISTED_SETTINGS_SCHEMA_KEYS = ['settingsSchemaVersion', 'settingsState'];
const LEGACY_AUTO_STEP_DELAY_KEYS = [];
const LEGACY_VERIFICATION_RESEND_COUNT_KEYS = [];
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;
function isPlainObjectValue(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
@@ -125,6 +129,7 @@ function normalizePlusPaymentMethod(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'gopay' || normalized === 'gpc-helper' ? normalized : 'paypal';
}
${extractFunction('normalizePlusAccountAccessStrategy')}
function normalizeSub2ApiGroupNames(value) {
return Array.isArray(value) ? value.map((entry) => String(entry || '').trim()).filter(Boolean) : [];
}
@@ -601,5 +601,5 @@ test('background wires sub2api session import executor into the workflow runtime
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/steps\/sub2api-session-import\.js/);
assert.match(source, /'sub2api-session-import': \(state\) => sub2ApiSessionImportExecutor\.executeSub2ApiSessionImport\(state\)/);
assert.match(source, /'sub2api-session-import',\s*\n\s*'oauth-login'/);
assert.match(source, /'sub2api-session-import',[\s\S]*'oauth-login'/);
});
+27 -3
View File
@@ -216,7 +216,7 @@ test('flow capability registry exposes editable Plus account access strategies f
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'sub2api_codex_session');
});
test('flow capability registry preserves requested Plus account strategy while forcing OAuth on unsupported targets', () => {
test('flow capability registry exposes editable Plus account access strategies for CPA', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
@@ -226,12 +226,36 @@ test('flow capability registry preserves requested Plus account strategy while f
openaiIntegrationTargetId: 'cpa',
signupMethod: 'email',
plusModeEnabled: true,
plusAccountAccessStrategy: 'sub2api_codex_session',
plusAccountAccessStrategy: 'cpa_codex_session',
},
});
assert.deepEqual(
capabilityState.availablePlusAccountAccessStrategies,
['oauth', 'cpa_codex_session']
);
assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'cpa_codex_session');
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 OAuth when the current target only supports OAuth', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
const capabilityState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'openai',
openaiIntegrationTargetId: 'codex2api',
signupMethod: 'email',
plusModeEnabled: true,
plusAccountAccessStrategy: 'cpa_codex_session',
},
});
assert.deepEqual(capabilityState.availablePlusAccountAccessStrategies, ['oauth']);
assert.equal(capabilityState.requestedPlusAccountAccessStrategy, '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');
@@ -99,3 +99,15 @@ test('settings schema can project canonical state into a read view without legac
assert.equal(view.settingsSchemaVersion, 4);
assert.equal(view.settingsState.activeFlowId, 'kiro');
});
test('settings schema preserves CPA session strategy in canonical state and read view', () => {
const { settingsSchema } = loadApis();
const schema = settingsSchema.createSettingsSchema();
const normalized = schema.normalizeSettingsState({
plusAccountAccessStrategy: 'cpa_codex_session',
});
const view = schema.buildSettingsView(normalized);
assert.equal(normalized.flows.openai.plus.plusAccountAccessStrategy, 'cpa_codex_session');
assert.equal(view.plusAccountAccessStrategy, 'cpa_codex_session');
});
+48 -4
View File
@@ -50,7 +50,7 @@ test('GoPay manual confirm executor publishes pending manual confirmation state
assert.equal(stateUpdates[0].plusManualConfirmationMethod, 'gopay');
assert.equal(stateUpdates[0].plusManualConfirmationStep, 7);
assert.match(String(stateUpdates[0].plusManualConfirmationRequestId || ''), /^gopay-/);
assert.match(stateUpdates[0].plusManualConfirmationMessage, /OAuth 登录/);
assert.match(stateUpdates[0].plusManualConfirmationMessage, /OAuth/);
assert.equal(broadcasts.length, 1);
assert.equal(broadcasts[0].plusManualConfirmationPending, true);
assert.deepStrictEqual(
@@ -59,7 +59,7 @@ test('GoPay manual confirm executor publishes pending manual confirmation state
);
assert.match(
events.find((event) => event.type === 'log')?.message || '',
/确认后继续OAuth 登录/
/OAuth/
);
});
@@ -100,9 +100,53 @@ test('GoPay manual confirm executor switches continuation copy to SUB2API sessio
assert.equal(stateUpdates.length, 1);
assert.equal(stateUpdates[0].plusManualConfirmationStep, 9);
assert.match(stateUpdates[0].plusManualConfirmationMessage, /导入当前 ChatGPT 会话到 SUB2API/);
assert.match(stateUpdates[0].plusManualConfirmationMessage, /SUB2API/);
assert.match(
events.find((event) => event.type === 'log')?.message || '',
/确认后继续导入当前 ChatGPT 会话到 SUB2API/
/SUB2API/
);
});
test('GoPay manual confirm executor switches continuation copy to CPA session import when the effective tail is CPA session-based', async () => {
const stateUpdates = [];
const events = [];
const executor = api.createGoPayManualConfirmExecutor({
addLog: async (message, level = 'info') => {
events.push({ type: 'log', message, level });
},
broadcastDataUpdate: () => {},
chrome: {
tabs: {
update: async () => {},
},
},
getNodeIdsForState: () => [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'gopay-subscription-confirm',
'cpa-session-import',
],
getTabId: async () => 42,
isTabAlive: async () => true,
registerTab: async () => {},
setState: async (payload) => {
stateUpdates.push(payload);
},
});
await executor.executeGoPayManualConfirm({
nodeId: 'gopay-subscription-confirm',
visibleStep: 9,
plusCheckoutTabId: 42,
plusCheckoutUrl: 'https://chatgpt.com/checkout/openai_llc/session',
});
assert.equal(stateUpdates.length, 1);
assert.equal(stateUpdates[0].plusManualConfirmationStep, 9);
assert.match(stateUpdates[0].plusManualConfirmationMessage, /CPA/);
assert.match(
events.find((event) => event.type === 'log')?.message || '',
/CPA/
);
});
@@ -57,6 +57,7 @@ const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const DEFAULT_PLUS_PAYMENT_METHOD = 'paypal';
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 = 'oauth';
const GPC_HELPER_PHONE_MODE_AUTO = 'auto';
const GPC_HELPER_PHONE_MODE_MANUAL = 'manual';
@@ -165,6 +166,33 @@ test('sidepanel enables SUB2API session strategy selection when the current Plus
assert.match(api.plusAccountAccessStrategyCaption.textContent, /SUB2API/);
});
test('sidepanel enables CPA session strategy selection when the current Plus target supports it', () => {
const api = buildHarness(
`{
canShowPlusSettings: true,
runtimeLocks: { plusModeEnabled: true },
canEditPlusAccountAccessStrategy: true,
availablePlusAccountAccessStrategies: ['oauth', 'cpa_codex_session'],
effectivePanelMode: 'cpa',
effectivePlusAccountAccessStrategy: 'cpa_codex_session',
}`,
`{
activeFlowId: 'openai',
panelMode: 'cpa',
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'cpa_codex_session',
}`
);
api.updatePlusModeUI();
assert.equal(api.rowPlusAccountAccessStrategy.style.display, '');
assert.equal(api.selectPlusAccountAccessStrategy.disabled, false);
assert.equal(api.selectPlusAccountAccessStrategy.value, 'cpa_codex_session');
assert.equal(api.getRequestedPlusAccountAccessStrategy(), 'cpa_codex_session');
assert.match(api.plusAccountAccessStrategyCaption.textContent, /CPA/);
});
test('sidepanel rebuilds step definitions and workflow nodes with the effective Plus account strategy', () => {
const bundle = [
extractFunction('normalizeSignupMethod'),
@@ -195,6 +223,7 @@ let currentPlusModeEnabled = false;
let currentPlusPaymentMethod = 'paypal';
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';
let currentPlusAccountAccessStrategy = 'oauth';
let currentSignupMethod = 'email';
let currentPhoneSignupReloginAfterBindEmailEnabled = false;
@@ -272,12 +301,20 @@ return {
test('background declares Plus account access strategy constants before precomputing session-tail step definitions', () => {
const strategyConstantIndex = backgroundSource.indexOf("const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';");
const cpaStrategyConstantIndex = backgroundSource.indexOf("const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session';");
const precomputedSessionStepsIndex = backgroundSource.indexOf('const PLUS_PAYPAL_SUB2API_SESSION_STEP_DEFINITIONS =');
const precomputedCpaSessionStepsIndex = backgroundSource.indexOf('const PLUS_PAYPAL_CPA_SESSION_STEP_DEFINITIONS =');
assert.ok(strategyConstantIndex >= 0, 'expected Plus account access strategy constant declaration');
assert.ok(cpaStrategyConstantIndex >= 0, 'expected CPA Plus account access strategy constant declaration');
assert.ok(precomputedSessionStepsIndex >= 0, 'expected precomputed SUB2API session step definitions');
assert.ok(precomputedCpaSessionStepsIndex >= 0, 'expected precomputed CPA session step definitions');
assert.ok(
strategyConstantIndex < precomputedSessionStepsIndex,
'strategy constant must be declared before background precomputes session-tail step definitions'
);
assert.ok(
cpaStrategyConstantIndex < precomputedCpaSessionStepsIndex,
'CPA strategy constant must be declared before background precomputes CPA session-tail step definitions'
);
});
@@ -85,6 +85,7 @@ let currentPlusModeEnabled = false;
let currentPlusPaymentMethod = 'paypal';
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;
let currentPlusAccountAccessStrategy = DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY;
let currentSignupMethod = 'email';
@@ -170,6 +171,7 @@ 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 rowPayPalAccount = { style: { display: '' } };
${bundle}
@@ -229,6 +231,7 @@ 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;
${bundle}
return {
@@ -272,6 +275,7 @@ let currentPlusModeEnabled = false;
let currentPlusPaymentMethod = 'paypal';
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;
let currentPlusAccountAccessStrategy = DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY;
let currentSignupMethod = 'email';
@@ -330,6 +334,7 @@ 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 btnGpcCardKeyPurchase = { style: { display: 'none' } };
@@ -432,6 +437,7 @@ 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 btnGpcCardKeyPurchase = { style: { display: 'none' } };
@@ -495,6 +501,7 @@ 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: '' } };
@@ -577,6 +584,7 @@ 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: '' } };
@@ -745,6 +753,7 @@ test('sidepanel resolves pending GoPay manual confirmation from DATA_UPDATED sta
const api = new Function(`
const events = [];
let latestState = {
activeFlowId: 'openai',
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: 'gopay-request-1',
plusManualConfirmationStep: 7,
@@ -799,6 +808,11 @@ return { events, syncPlusManualConfirmationDialog };
test('sidepanel resolves pending GPC OTP with typed code', async () => {
const bundle = [
extractFunction('normalizeSignupMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('normalizePlusStrategyTargetId'),
extractFunction('getPlusAccountAccessStrategyContinuationLabel'),
extractFunction('resolvePlusManualContinuationActionLabelFromState'),
extractLastFunction('openPlusManualConfirmationDialog'),
extractLastFunction('syncPlusManualConfirmationDialog'),
].join('\n');
@@ -815,6 +829,14 @@ let latestState = {
};
let activePlusManualConfirmationRequestId = '';
let plusManualConfirmationDialogInFlight = false;
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const SIGNUP_METHOD_EMAIL = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
const DEFAULT_SIGNUP_METHOD = 'email';
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 = 'oauth';
const sharedFormDialog = {
async open(options) {
events.push({ type: 'form', options });
+64
View File
@@ -335,6 +335,70 @@ test('Plus phone signup never switches to SUB2API session tail even if the reque
assert.equal(stepKeys.includes('platform-verify'), true);
});
test('Plus session strategy swaps the OAuth tail for a single CPA import node', () => {
const source = fs.readFileSync('data/step-definitions.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageStepDefinitions;`)(globalScope);
const forbiddenTailKeys = [
'oauth-login',
'fetch-login-code',
'post-login-phone-verification',
'confirm-oauth',
'platform-verify',
];
[
{
label: 'paypal',
options: {
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'cpa_codex_session',
},
previousNodeId: 'plus-checkout-return',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
},
{
label: 'gopay',
options: {
plusModeEnabled: true,
plusPaymentMethod: 'gopay',
plusAccountAccessStrategy: 'cpa_codex_session',
},
previousNodeId: 'gopay-subscription-confirm',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 10],
},
{
label: 'gpc-helper',
options: {
plusModeEnabled: true,
plusPaymentMethod: 'gpc-helper',
plusAccountAccessStrategy: 'cpa_codex_session',
},
previousNodeId: 'plus-checkout-billing',
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 10],
},
].forEach(({ label, options, previousNodeId, expectedStepIds }) => {
const steps = api.getSteps(options);
const nodes = api.getNodes(options);
const stepKeys = steps.map((step) => step.key);
const nodeIds = nodes.map((node) => node.nodeId);
const previousNode = nodes.find((node) => node.nodeId === previousNodeId);
const sessionImportNode = nodes.find((node) => node.nodeId === 'cpa-session-import');
assert.equal(stepKeys.at(-1), 'cpa-session-import', `${label} should end with CPA session import`);
assert.equal(nodeIds.at(-1), 'cpa-session-import', `${label} node order should end with CPA session import`);
forbiddenTailKeys.forEach((key) => {
assert.equal(stepKeys.includes(key), false, `${label} should not keep ${key} in CPA session mode`);
assert.equal(nodeIds.includes(key), false, `${label} nodes should not keep ${key} in CPA session mode`);
});
assert.deepStrictEqual(api.getStepIds(options), expectedStepIds, `${label} step ids should follow the CPA tail`);
assert.equal(api.getLastStepId(options), expectedStepIds.at(-1), `${label} last step id should match CPA session import`);
assert.deepStrictEqual(previousNode?.next, ['cpa-session-import'], `${label} previous node should link to CPA session import`);
assert.deepStrictEqual(sessionImportNode?.next, [], `${label} CPA session import should be terminal`);
});
});
test('sidepanel html loads shared step definitions before sidepanel bootstrap', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const definitionsIndex = html.indexOf('<script src="../data/step-definitions.js"></script>');