Files
FlowPilot/tests/background-sub2api-session-import.test.js
T
2026-05-21 07:21:34 +08:00

606 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
function createJsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(payload),
};
}
function loadSub2ApiApiModule() {
const source = fs.readFileSync('background/sub2api-api.js', 'utf8');
return new Function('self', `${source}; return self.MultiPageBackgroundSub2ApiApi;`)({});
}
function loadSub2ApiSessionImportModule() {
const source = fs.readFileSync('flows/openai/background/steps/sub2api-session-import.js', 'utf8');
return new Function('self', `${source}; return self.MultiPageBackgroundSub2ApiSessionImport;`)({});
}
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('sub2api api imports current ChatGPT session through codex-session endpoint', async () => {
const apiModule = loadSub2ApiApiModule();
const fetchCalls = [];
const logs = [];
const importExpiresAt = Math.floor(Date.parse('2026-05-20T12:34:56.000Z') / 1000);
const api = apiModule.createSub2ApiApi({
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
normalizeSub2ApiUrl: (value) => value,
DEFAULT_SUB2API_GROUP_NAME: 'codex',
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 || 'GET', body });
if (parsed.pathname === '/api/v1/auth/login') {
return createJsonResponse({
code: 0,
data: {
access_token: 'admin-token',
},
});
}
if (parsed.pathname === '/api/v1/admin/groups/all') {
return createJsonResponse({
code: 0,
data: [
{ id: 5, name: 'codex', platform: 'openai' },
],
});
}
if (parsed.pathname === '/api/v1/admin/proxies/all') {
return createJsonResponse({
code: 0,
data: [{
id: 7,
name: 'shadowrocket',
protocol: 'socks5',
host: '127.0.0.1',
port: 1080,
status: 'active',
}],
});
}
if (parsed.pathname === '/api/v1/admin/accounts/import/codex-session') {
const parsedContent = JSON.parse(body.content);
assert.equal(parsedContent.accessToken, 'access-token-from-state');
assert.equal(parsedContent.user?.email, 'flow@example.com');
assert.equal(body.name, 'flow@example.com');
assert.equal(body.priority, 3);
assert.equal(body.proxy_id, 7);
assert.deepStrictEqual(body.group_ids, [5]);
assert.equal(body.auto_pause_on_expired, true);
assert.equal(body.update_existing, true);
assert.equal(body.expires_at, importExpiresAt);
return createJsonResponse({
code: 0,
data: {
total: 1,
created: 0,
updated: 1,
skipped: 0,
failed: 0,
warnings: [{
index: 1,
message: '未包含 refresh_tokenaccessToken 过期后无法自动续期',
}],
},
});
}
return createJsonResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
},
});
const result = await api.importCurrentChatGptSession({
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
sub2apiDefaultProxyName: 'shadowrocket',
sub2apiAccountPriority: 3,
session: {
accessToken: 'access-token-from-session',
expires: '2026-05-20T12:34:56.000Z',
user: {
email: 'flow@example.com',
name: 'dyson willion',
},
},
accessToken: 'access-token-from-state',
email: 'registration@example.com',
}, {
logLabel: '步骤 10',
logOptions: { step: 10, stepKey: 'sub2api-session-import' },
timeoutMs: 120000,
});
const importCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts/import/codex-session');
assert.ok(importCall, 'expected codex-session import call');
assert.equal(result.verifiedStatus, 'SUB2API 会话导入完成:新建 0,更新 1,跳过 0,失败 0');
assert.equal(result.sub2apiImportUpdated, 1);
assert.equal(
logs.some((entry) => entry.level === 'warn' && /refresh_token/.test(entry.message)),
true
);
});
test('sub2api session import falls back to JWT email before registration email', async () => {
const apiModule = loadSub2ApiApiModule();
const jwtToken = createJwtToken({ email: 'jwt@example.com' });
let importBody = null;
const api = apiModule.createSub2ApiApi({
addLog: async () => {},
normalizeSub2ApiUrl: (value) => value,
DEFAULT_SUB2API_GROUP_NAME: 'codex',
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
const body = options.body ? JSON.parse(options.body) : null;
if (parsed.pathname === '/api/v1/auth/login') {
return createJsonResponse({ code: 0, data: { access_token: 'admin-token' } });
}
if (parsed.pathname === '/api/v1/admin/groups/all') {
return createJsonResponse({
code: 0,
data: [{ id: 5, name: 'codex', platform: 'openai' }],
});
}
if (parsed.pathname === '/api/v1/admin/accounts/import/codex-session') {
importBody = body;
return createJsonResponse({
code: 0,
data: { total: 1, created: 1, updated: 0, skipped: 0, failed: 0 },
});
}
return createJsonResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
},
});
await api.importCurrentChatGptSession({
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
session: {
accessToken: jwtToken,
user: {
name: 'dyson willion',
},
},
accessToken: jwtToken,
email: 'registration@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'identifier@example.com',
});
assert.ok(importBody, 'expected codex-session import call');
assert.equal(importBody.name, 'jwt@example.com');
});
test('sub2api session import falls back to registration email when session has no readable email', async () => {
const apiModule = loadSub2ApiApiModule();
let importBody = null;
const api = apiModule.createSub2ApiApi({
addLog: async () => {},
normalizeSub2ApiUrl: (value) => value,
DEFAULT_SUB2API_GROUP_NAME: 'codex',
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
const body = options.body ? JSON.parse(options.body) : null;
if (parsed.pathname === '/api/v1/auth/login') {
return createJsonResponse({ code: 0, data: { access_token: 'admin-token' } });
}
if (parsed.pathname === '/api/v1/admin/groups/all') {
return createJsonResponse({
code: 0,
data: [{ id: 5, name: 'codex', platform: 'openai' }],
});
}
if (parsed.pathname === '/api/v1/admin/accounts/import/codex-session') {
importBody = body;
return createJsonResponse({
code: 0,
data: { total: 1, created: 1, updated: 0, skipped: 0, failed: 0 },
});
}
return createJsonResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
},
});
await api.importCurrentChatGptSession({
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
session: {
accessToken: 'opaque-token',
user: {
name: 'dyson willion',
},
},
accessToken: 'opaque-token',
email: 'registration@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'identifier@example.com',
});
assert.ok(importBody, 'expected codex-session import call');
assert.equal(importBody.name, 'registration@example.com');
});
test('session import step reads current ChatGPT session and completes node', async () => {
const moduleApi = loadSub2ApiSessionImportModule();
const completed = [];
const logs = [];
const ensureCalls = [];
const sentMessages = [];
const importedPayloads = [];
const executor = moduleApi.createSub2ApiSessionImportExecutor({
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 });
},
createSub2ApiApi: () => ({
importCurrentChatGptSession: async (state, options) => {
importedPayloads.push({ state, options });
return {
verifiedStatus: 'SUB2API 会话导入完成:新建 1,更新 0,跳过 0,失败 0',
sub2apiImportCreated: 1,
sub2apiImportUpdated: 0,
sub2apiImportSkipped: 0,
sub2apiImportFailed: 0,
};
},
}),
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options = {}) => {
ensureCalls.push({ source, tabId, options });
},
getTabId: async () => null,
isTabAlive: async () => false,
normalizeSub2ApiUrl: (value) => value,
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 () => {},
DEFAULT_SUB2API_GROUP_NAME: 'codex',
});
await executor.executeSub2ApiSessionImport({
nodeId: 'sub2api-session-import',
visibleStep: 10,
plusCheckoutTabId: 91,
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
});
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',
'flows/openai/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: 'sub2api-session-import',
payload: {
verifiedStatus: 'SUB2API 会话导入完成:新建 1,更新 0,跳过 0,失败 0',
sub2apiImportCreated: 1,
sub2apiImportUpdated: 0,
sub2apiImportSkipped: 0,
sub2apiImportFailed: 0,
},
});
assert.equal(
logs.some((entry) => entry.stepKey === 'sub2api-session-import' && /读取当前 ChatGPT 登录会话/.test(entry.message)),
true
);
});
test('session import step falls back to an active ChatGPT tab when no checkout tab is tracked', async () => {
const moduleApi = loadSub2ApiSessionImportModule();
const completed = [];
const importedPayloads = [];
const queryCalls = [];
const registerCalls = [];
const sentMessages = [];
const sessionTab = {
id: 77,
url: 'https://chatgpt.com/?model=gpt-4o',
active: true,
currentWindow: true,
lastAccessed: 1234,
};
const executor = moduleApi.createSub2ApiSessionImportExecutor({
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 });
},
createSub2ApiApi: () => ({
importCurrentChatGptSession: async (state, options) => {
importedPayloads.push({ state, options });
return {
verifiedStatus: 'SUB2API 会话导入完成:新建 1,更新 0,跳过 0,失败 0',
sub2apiImportCreated: 1,
sub2apiImportUpdated: 0,
sub2apiImportSkipped: 0,
sub2apiImportFailed: 0,
};
},
}),
ensureContentScriptReadyOnTabUntilStopped: async () => {},
getTabId: async () => null,
isTabAlive: async () => false,
normalizeSub2ApiUrl: (value) => value,
registerTab: async (source, tabId) => {
registerCalls.push({ source, tabId });
},
sendTabMessageUntilStopped: async (tabId, source, message) => {
sentMessages.push({ tabId, source, message });
return {
session: {
accessToken: 'session-access-token',
user: {
email: 'fallback@example.com',
},
},
accessToken: 'session-access-token',
};
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabCompleteUntilStopped: async () => {},
DEFAULT_SUB2API_GROUP_NAME: 'codex',
});
await executor.executeSub2ApiSessionImport({
nodeId: 'sub2api-session-import',
visibleStep: 10,
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
});
assert.deepStrictEqual(queryCalls, [
{ active: true, currentWindow: true },
{},
]);
assert.deepStrictEqual(registerCalls, [{
source: 'plus-checkout',
tabId: 77,
}]);
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].tabId, 77);
assert.equal(importedPayloads.length, 1);
assert.equal(importedPayloads[0].state.session.user.email, 'fallback@example.com');
assert.equal(completed.length, 1);
});
test('session import step ignores unusable tracked tabs and prefers a real ChatGPT tab from open tabs', async () => {
const moduleApi = loadSub2ApiSessionImportModule();
const registerCalls = [];
const sentMessages = [];
const tabsById = {
91: {
id: 91,
url: 'https://www.paypal.com/checkoutnow',
active: false,
currentWindow: false,
lastAccessed: 10,
},
101: {
id: 101,
url: 'https://platform.openai.com/settings/profile',
active: true,
currentWindow: true,
lastAccessed: 50,
},
203: {
id: 203,
url: 'https://chatgpt.com/c/abc123',
active: false,
currentWindow: false,
lastAccessed: 40,
},
};
const executor = moduleApi.createSub2ApiSessionImportExecutor({
addLog: async () => {},
chrome: {
tabs: {
get: async (tabId) => tabsById[tabId] || null,
query: async (queryInfo = {}) => {
if (queryInfo.active && queryInfo.currentWindow) {
return [tabsById[101]];
}
return [tabsById[101], tabsById[203]];
},
update: async () => {},
},
},
completeNodeFromBackground: async () => {},
createSub2ApiApi: () => ({
importCurrentChatGptSession: async () => ({
verifiedStatus: 'SUB2API 会话导入完成:新建 1,更新 0,跳过 0,失败 0',
}),
}),
ensureContentScriptReadyOnTabUntilStopped: async () => {},
getTabId: async () => 91,
isTabAlive: async () => true,
normalizeSub2ApiUrl: (value) => value,
registerTab: async (source, tabId) => {
registerCalls.push({ source, tabId });
},
sendTabMessageUntilStopped: async (tabId, source, message) => {
sentMessages.push({ tabId, source, message });
return {
session: {
accessToken: 'session-access-token',
user: {
email: 'best-match@example.com',
},
},
accessToken: 'session-access-token',
};
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabCompleteUntilStopped: async () => {},
DEFAULT_SUB2API_GROUP_NAME: 'codex',
});
await executor.executeSub2ApiSessionImport({
nodeId: 'sub2api-session-import',
visibleStep: 10,
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
});
assert.deepStrictEqual(registerCalls, [{
source: 'plus-checkout',
tabId: 203,
}]);
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].tabId, 203);
});
test('session import step reports missing readable session tab when tracked tabs are unusable', async () => {
const moduleApi = loadSub2ApiSessionImportModule();
let sendCalled = false;
const executor = moduleApi.createSub2ApiSessionImportExecutor({
addLog: async () => {},
chrome: {
tabs: {
get: async (tabId) => ({
id: tabId,
url: 'https://example.com/not-chatgpt',
}),
update: async () => {},
},
},
completeNodeFromBackground: async () => {},
createSub2ApiApi: () => ({
importCurrentChatGptSession: async () => ({}),
}),
ensureContentScriptReadyOnTabUntilStopped: async () => {},
getTabId: async () => 91,
isTabAlive: async () => true,
normalizeSub2ApiUrl: (value) => value,
sendTabMessageUntilStopped: async () => {
sendCalled = true;
return {};
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabCompleteUntilStopped: async () => {},
});
await assert.rejects(
() => executor.executeSub2ApiSessionImport({
nodeId: 'sub2api-session-import',
visibleStep: 10,
}),
/未找到可读取 ChatGPT 会话的标签页/
);
assert.equal(sendCalled, false);
});
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\S]*'oauth-login'/);
});