修复设置自动保存与标签清理边界

This commit is contained in:
QLHazyCoder
2026-05-20 05:15:56 +08:00
119 changed files with 24116 additions and 3966 deletions
+329
View File
@@ -0,0 +1,329 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/signup-page.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 i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
function extractConst(name) {
const pattern = new RegExp(`const\\s+${name}\\s*=\\s*[\\s\\S]*?;`);
const match = source.match(pattern);
if (!match) {
throw new Error(`missing const ${name}`);
}
return match[0];
}
test('login step recognizes Japanese email and phone entry actions', () => {
const api = new Function(`
const emailButton = {
textContent: 'メールアドレスで続行',
disabled: false,
getAttribute(name) {
if (name === 'aria-disabled') return 'false';
return '';
},
};
const phoneButton = {
textContent: '電話番号で続行',
disabled: false,
getAttribute(name) {
if (name === 'aria-disabled') return 'false';
return '';
},
};
const codeButton = {
textContent: 'コードでサインイン',
disabled: false,
getAttribute(name) {
if (name === 'aria-disabled') return 'false';
return '';
},
};
const document = {
querySelectorAll(selector) {
if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
return [codeButton, phoneButton, emailButton];
}
return [];
},
};
${extractConst('ONE_TIME_CODE_LOGIN_PATTERN')}
${extractConst('LOGIN_ENTRY_ACTION_PATTERN')}
${extractConst('LOGIN_SWITCH_TO_PHONE_PATTERN')}
${extractConst('LOGIN_PHONE_ACTION_PATTERN')}
${extractConst('LOGIN_EXTERNAL_IDP_PATTERN')}
${extractConst('LOGIN_CODE_ONLY_ACTION_PATTERN')}
function isVisibleElement(el) { return Boolean(el); }
function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; }
function getActionText(el) { return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')].filter(Boolean).join(' ').replace(/\\s+/g, ' ').trim(); }
${extractFunction('findLoginEntryTrigger')}
${extractFunction('findLoginPhoneEntryTrigger')}
${extractFunction('findOneTimeCodeLoginTrigger')}
return {
email() { return getActionText(findLoginEntryTrigger()); },
phone() { return getActionText(findLoginPhoneEntryTrigger()); },
code() { return getActionText(findOneTimeCodeLoginTrigger()); },
};
`)();
assert.equal(api.email(), 'メールアドレスで続行');
assert.equal(api.phone(), '電話番号で続行');
assert.equal(api.code(), 'コードでサインイン');
});
test('login submit button recognizes Japanese login and continue labels', () => {
const api = new Function(`
const loginButton = {
textContent: 'ログイン',
disabled: false,
getAttribute(name) {
if (name === 'type') return 'button';
if (name === 'aria-disabled') return 'false';
return '';
},
};
const document = {
querySelector() { return null; },
querySelectorAll(selector) {
if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
return [loginButton];
}
return [];
},
};
${extractConst('ONE_TIME_CODE_LOGIN_PATTERN')}
function isVisibleElement(el) { return Boolean(el); }
function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; }
function getActionText(el) { return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')].filter(Boolean).join(' ').replace(/\\s+/g, ' ').trim(); }
${extractFunction('getLoginSubmitButton')}
return {
run() { return getActionText(getLoginSubmitButton()); },
};
`)();
assert.equal(api.run(), 'ログイン');
});
test('verification helpers recognize Japanese resend and submit actions', () => {
const api = new Function(`
const resendButton = {
textContent: 'コードを再送信',
disabled: false,
getAttribute(name) {
if (name === 'aria-disabled') return 'false';
return '';
},
};
const verifyButton = {
textContent: '確認',
disabled: false,
getAttribute(name) {
if (name === 'aria-disabled') return 'false';
return '';
},
};
const codeInput = {
form: {
querySelectorAll(selector) {
if (selector === 'button[type="submit"], input[type="submit"]') return [];
if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [verifyButton];
return [];
},
},
closest() { return null; },
};
const document = {
querySelectorAll(selector) {
if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
return [resendButton];
}
return [];
},
};
${extractConst('RESEND_VERIFICATION_CODE_PATTERN')}
function isVisibleElement(el) { return Boolean(el); }
function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; }
function getActionText(el) { return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')].filter(Boolean).join(' ').replace(/\\s+/g, ' ').trim(); }
${extractFunction('findResendVerificationCodeTrigger')}
${extractFunction('getVerificationSubmitButtonForTarget')}
return {
resend() { return getActionText(findResendVerificationCodeTrigger()); },
submit() { return getActionText(getVerificationSubmitButtonForTarget(codeInput)); },
};
`)();
assert.equal(api.resend(), 'コードを再送信');
assert.equal(api.submit(), '確認');
});
test('OAuth consent detection recognizes Japanese consent copy and continue button', () => {
const api = new Function(`
const continueButton = {
textContent: '続行',
disabled: false,
getAttribute(name) {
if (name === 'data-dd-action-name') return '';
if (name === 'aria-disabled') return 'false';
return '';
},
};
const consentForm = {
querySelectorAll(selector) {
if (selector === 'button[type="submit"], input[type="submit"], [role="button"]') {
return [continueButton];
}
return [];
},
};
const document = {
body: {
innerText: 'ChatGPTを使用してCodexにサインイン',
textContent: 'ChatGPTを使用してCodexにサインイン',
},
querySelector(selector) {
if (selector === OAUTH_CONSENT_FORM_SELECTOR) return consentForm;
return null;
},
querySelectorAll(selector) {
if (selector === 'button, [role="button"]') return [continueButton];
return [];
},
};
${extractConst('OAUTH_CONSENT_PAGE_PATTERN')}
${extractConst('OAUTH_CONSENT_FORM_SELECTOR')}
${extractConst('CONTINUE_ACTION_PATTERN')}
function isVisibleElement(el) { return Boolean(el); }
function getActionText(el) { return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')].filter(Boolean).join(' ').replace(/\\s+/g, ' ').trim(); }
${extractFunction('getPageTextSnapshot')}
${extractFunction('getOAuthConsentForm')}
${extractFunction('getPrimaryContinueButton')}
${extractFunction('isOAuthConsentPage')}
return {
consent() { return isOAuthConsentPage(); },
button() { return getActionText(getPrimaryContinueButton()); },
};
`)();
assert.equal(api.consent(), true);
assert.equal(api.button(), '続行');
});
test('profile step recognizes Japanese account creation and day label', () => {
const api = new Function(`
const submitButton = {
textContent: 'アカウントを作成',
getAttribute(name) {
if (name === 'type') return 'button';
return '';
},
};
const dayLabel = { textContent: '日' };
const dayRoot = {
querySelectorAll(selector) {
if (selector === 'span') return [dayLabel];
return [];
},
querySelector(selector) {
if (selector === 'button[aria-haspopup="listbox"]') return { id: 'day-button' };
if (selector === '.react-aria-SelectValue') return { textContent: '' };
return null;
},
closest() { return null; },
parentElement: {
querySelector(selector) {
if (selector === '[data-testid="hidden-select-container"] select') return { id: 'day-select' };
return null;
},
},
};
const document = {
querySelector(selector) {
if (selector === 'button[type="submit"], input[type="submit"]') return null;
return null;
},
querySelectorAll(selector) {
if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [submitButton];
if (selector === '.react-aria-Select') return [dayRoot];
return [];
},
};
function isVisibleElement(el) { return Boolean(el); }
function getActionText(el) { return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')].filter(Boolean).join(' ').replace(/\\s+/g, ' ').trim(); }
${extractFunction('normalizeInlineText')}
${extractFunction('getStep5SubmitButton')}
${extractFunction('findBirthdayReactAriaSelect')}
return {
submit() { return getActionText(getStep5SubmitButton()); },
day() { return findBirthdayReactAriaSelect('日')?.nativeSelect?.id || ''; },
};
`)();
assert.equal(api.submit(), 'アカウントを作成');
assert.equal(api.day(), 'day-select');
});
+420
View File
@@ -0,0 +1,420 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('auto-run controller preserves kiro flow across fresh reset and starts from the kiro first node', async () => {
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
const executedNodeIds = [];
const kiroNodeIds = [
'kiro-open-register-page',
'kiro-submit-email',
'kiro-submit-name',
'kiro-submit-verification-code',
'kiro-submit-password',
'kiro-complete-register-consent',
'kiro-start-desktop-authorize',
'kiro-complete-desktop-authorize',
'kiro-upload-credential',
];
const openAiNodeIds = ['open-chatgpt', 'submit-signup-email', 'fill-password'];
let helperCalls = 0;
let sessionSeed = 700;
let currentState = {
activeFlowId: 'kiro',
flowId: 'kiro',
panelMode: 'cpa',
kiroTargetId: 'kiro-rs',
kiroRsUrl: 'https://kiro.example/admin',
kiroRsKey: 'demo-key',
customFutureFlowField: 'future-ready',
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
phoneVerificationEnabled: false,
phoneSignupReloginAfterBindEmailEnabled: false,
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
signupMethod: 'email',
stepExecutionRangeByFlow: {
openai: { enabled: false, fromStep: 1, toStep: 11 },
kiro: { enabled: false, fromStep: 1, toStep: 9 },
},
nodeStatuses: {
'open-chatgpt': 'stopped',
'kiro-open-register-page': 'pending',
'kiro-submit-email': 'pending',
'kiro-submit-name': 'pending',
'kiro-submit-verification-code': 'pending',
'kiro-submit-password': 'pending',
'kiro-complete-register-consent': 'pending',
'kiro-start-desktop-authorize': 'pending',
'kiro-complete-desktop-authorize': 'pending',
'kiro-upload-credential': 'pending',
},
tabRegistry: {
stale: { tabId: 99 },
},
sourceLastUrls: {
stale: 'https://chatgpt.com/',
},
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = {
...this.state,
...updates,
};
},
};
const controller = api.createAutoRunController({
addLog: async () => {},
appendAccountRunRecord: async () => null,
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}, extraState = {}) => {
currentState = {
...currentState,
...extraState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? currentState.autoRunCurrentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? currentState.autoRunTotalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? currentState.autoRunAttemptRun ?? 0,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
buildFreshAutoRunKeepState: (prevState = {}, context = {}) => {
helperCalls += 1;
assert.equal(context.targetRun, 1);
assert.equal(context.attemptRun, 1);
return {
activeFlowId: prevState.activeFlowId,
flowId: prevState.activeFlowId,
panelMode: prevState.panelMode,
kiroTargetId: prevState.kiroTargetId,
kiroRsUrl: prevState.kiroRsUrl,
kiroRsKey: prevState.kiroRsKey,
customFutureFlowField: prevState.customFutureFlowField,
};
},
ensureHotmailMailboxReadyForAutoRunRound: async () => {},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedNodeId: (statuses = {}, state = {}) => {
const flowId = String(state?.activeFlowId || state?.flowId || 'openai').trim().toLowerCase();
const candidateNodeIds = flowId === 'kiro' ? kiroNodeIds : openAiNodeIds;
for (const nodeId of candidateNodeIds) {
const status = String(statuses?.[nodeId] || 'pending').trim().toLowerCase();
if (!['completed', 'manual_completed', 'skipped'].includes(status)) {
return nodeId;
}
}
return '';
},
getPendingAutoRunTimerPlan: () => null,
getRunningNodeIds: () => [],
getState: async () => ({
...currentState,
nodeStatuses: { ...(currentState.nodeStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedNodeProgress: () => false,
isAddPhoneAuthFailure: () => false,
isGpcTaskEndedFailure: () => false,
isPhoneSmsPlatformRateLimitFailure: () => false,
isPlusCheckoutNonFreeTrialFailure: () => false,
isRestartCurrentAttemptError: () => false,
isStep4Route405RecoveryLimitFailure: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStopError: () => false,
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Number(value) || 0),
persistAutoRunTimerPlan: async () => {},
resetState: async () => {
currentState = {
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'cpa',
kiroTargetId: '',
kiroRsUrl: '',
kiroRsKey: '',
customFutureFlowField: '',
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
phoneVerificationEnabled: false,
phoneSignupReloginAfterBindEmailEnabled: false,
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
signupMethod: 'email',
stepExecutionRangeByFlow: {
openai: { enabled: false, fromStep: 1, toStep: 11 },
kiro: { enabled: false, fromStep: 1, toStep: 9 },
},
nodeStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromNode: async (nodeId) => {
executedNodeIds.push(nodeId);
assert.equal(currentState.activeFlowId, 'kiro');
assert.equal(currentState.flowId, 'kiro');
assert.equal(currentState.kiroTargetId, 'kiro-rs');
assert.equal(currentState.customFutureFlowField, 'future-ready');
currentState = {
...currentState,
nodeStatuses: {
'kiro-open-register-page': 'completed',
'kiro-submit-email': 'completed',
'kiro-submit-name': 'completed',
'kiro-submit-verification-code': 'completed',
'kiro-submit-password': 'completed',
'kiro-complete-register-consent': 'completed',
'kiro-start-desktop-authorize': 'completed',
'kiro-complete-desktop-authorize': 'completed',
'kiro-upload-credential': 'completed',
},
};
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
nodeStatuses: updates.nodeStatuses
? { ...updates.nodeStatuses }
: currentState.nodeStatuses,
tabRegistry: updates.tabRegistry
? { ...updates.tabRegistry }
: currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls
? { ...updates.sourceLastUrls }
: currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: () => {},
waitForRunningNodesToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage: () => Promise.resolve(),
},
},
});
await controller.autoRunLoop(1, { autoRunSkipFailures: false, mode: 'restart' });
assert.deepStrictEqual(executedNodeIds, ['kiro-open-register-page']);
assert.equal(helperCalls, 1);
});
test('auto-run controller stops immediately on kiro proxy failures even when skip-failures is enabled', async () => {
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const controllerApi = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
const kiroNodeIds = [
'kiro-open-register-page',
'kiro-submit-email',
'kiro-submit-name',
'kiro-submit-verification-code',
'kiro-submit-password',
'kiro-complete-register-consent',
'kiro-start-desktop-authorize',
'kiro-complete-desktop-authorize',
'kiro-upload-credential',
];
const events = {
logs: [],
phases: [],
runCalls: 0,
cancelReasons: [],
records: [],
};
let currentState = {
activeFlowId: 'kiro',
flowId: 'kiro',
autoRunSkipFailures: true,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
nodeStatuses: {
'kiro-open-register-page': 'pending',
'kiro-submit-email': 'pending',
'kiro-submit-name': 'pending',
'kiro-submit-verification-code': 'pending',
'kiro-submit-password': 'pending',
'kiro-complete-register-consent': 'pending',
'kiro-start-desktop-authorize': 'pending',
'kiro-complete-desktop-authorize': 'pending',
'kiro-upload-credential': 'pending',
},
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
let sessionSeed = 900;
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
const controller = controllerApi.createAutoRunController({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
appendAccountRunRecord: async (status, _state, reason) => {
events.records.push({ status, reason });
return { status, reason };
},
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
events.phases.push({ phase, ...payload });
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? currentState.autoRunCurrentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? currentState.autoRunTotalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? currentState.autoRunAttemptRun ?? 0,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: (reason) => {
events.cancelReasons.push(reason);
},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
ensureHotmailMailboxReadyForAutoRunRound: async () => {},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedNodeId: (statuses = {}) => {
for (const nodeId of kiroNodeIds) {
const status = String(statuses?.[nodeId] || 'pending').trim().toLowerCase();
if (!['completed', 'manual_completed', 'skipped'].includes(status)) {
return nodeId;
}
}
return '';
},
getPendingAutoRunTimerPlan: () => null,
getRunningNodeIds: () => [],
getState: async () => ({
...currentState,
nodeStatuses: { ...(currentState.nodeStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedNodeProgress: () => false,
isAddPhoneAuthFailure: () => false,
isGpcTaskEndedFailure: () => false,
isKiroProxyFailure: (error) => /Kiro 注册页出现 AWS 请求异常|Kiro 注册页返回 403|切换代理|更换代理/i.test(error?.message || String(error || '')),
isPhoneSmsPlatformRateLimitFailure: () => false,
isPlusCheckoutNonFreeTrialFailure: () => false,
isRestartCurrentAttemptError: () => false,
isStep4Route405RecoveryLimitFailure: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStopError: () => false,
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Number(value) || 0),
persistAutoRunTimerPlan: async () => {},
resetState: async () => {},
runAutoSequenceFromNode: async (nodeId) => {
events.runCalls += 1;
assert.equal(nodeId, 'kiro-open-register-page');
throw new Error('Kiro 注册页出现 AWS 请求异常,通常是当前代理 IP 或出口区域异常,请先切换代理后再重试。');
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
nodeStatuses: updates.nodeStatuses ? { ...updates.nodeStatuses } : currentState.nodeStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: () => {},
waitForRunningNodesToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage: () => Promise.resolve(),
},
},
});
await controller.autoRunLoop(2, { autoRunSkipFailures: true, mode: 'restart' });
assert.equal(events.runCalls, 1);
assert.equal(events.phases[0]?.phase, 'running');
assert.equal(events.phases.at(-1)?.phase, 'stopped');
assert.equal(events.phases.some((entry) => entry.phase === 'retrying'), false);
assert.equal(events.cancelReasons.length, 1);
assert.match(events.cancelReasons[0], /Kiro 代理异常页/);
assert.ok(events.logs.some((entry) => /切换代理/.test(entry.message)));
assert.ok(events.records.some((entry) => /failed$/i.test(entry.status)));
});
+43 -4
View File
@@ -173,8 +173,8 @@ const defaultStepDefinitions = {
4: { key: 'verify-email' },
5: { key: 'profile-basic' },
6: { key: 'profile-finish' },
7: { key: 'auth-login' },
8: { key: 'auth-email-code' },
7: { key: 'oauth-login' },
8: { key: 'fetch-login-code' },
9: { key: 'confirm-oauth' },
10: { key: 'platform-verify' },
};
@@ -407,7 +407,7 @@ test('auto-run keeps restarting from step 7 after post-login failures without a
7, 8, 9, 10,
]
);
assert.ok(events.logs.some(({ message }) => /回到节点 auth-login 重新开始授权流程/.test(message)));
assert.ok(events.logs.some(({ message }) => /回到节点 oauth-login 重新开始授权流程/.test(message)));
});
test('auto-run restarts the current step after five minutes without new logs', async () => {
@@ -559,7 +559,7 @@ test('auto-run restarts from confirm-oauth step after transient step10 token_exc
stepStatuses: { 3: 'completed' },
stepsVersion: 'ultra2.0',
visibleStep: 10,
contributionMode: false,
accountContributionEnabled: false,
},
});
@@ -964,3 +964,42 @@ test('auto-run does not restart GPC checkout when account already has a ChatGPT
assert.equal(result.events.invalidations.length, 0);
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message)));
});
test('auto-run does not reroute SUB2API session import failures into OAuth restarts', async () => {
const plusSessionSteps = {
6: { key: 'plus-checkout-create' },
7: { key: 'plus-checkout-billing' },
8: { key: 'paypal-approve' },
9: { key: 'plus-checkout-return' },
10: { key: 'sub2api-session-import' },
};
const harness = createHarness({
startStep: 10,
failureStep: 10,
failureBudget: 1,
failureMessage: '步骤 10:当前页面未读取到有效的 ChatGPT session。',
stepDefinitions: plusSessionSteps,
finalOAuthChainStartStep: 10,
customState: {
stepStatuses: {
3: 'completed',
6: 'completed',
7: 'completed',
8: 'completed',
9: 'completed',
},
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
panelMode: 'sub2api',
plusAccountAccessStrategy: 'sub2api_codex_session',
},
});
const result = await harness.runAndCaptureError();
assert.ok(result?.error);
assert.match(result.error.message, /未读取到有效的 ChatGPT session/);
assert.deepStrictEqual(result.events.steps, [10]);
assert.equal(result.events.invalidations.length, 0);
assert.ok(!result.events.logs.some(({ message }) => /回到节点 oauth-login|回到节点 confirm-oauth|重新开始授权流程/.test(message)));
});
@@ -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'),
@@ -121,9 +122,14 @@ const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const SIGNUP_METHOD_EMAIL = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
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';
@@ -132,6 +138,27 @@ const FIVE_SIM_OPERATOR = 'any';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const self = {
MultiPageFlowRegistry: {
DEFAULT_KIRO_RS_URL: '',
normalizeFlowId(value, fallback = 'openai') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'kiro') {
return 'kiro';
}
if (normalized === 'codex' || normalized === 'openai') {
return 'openai';
}
return String(fallback || 'openai').trim().toLowerCase() === 'kiro' ? 'kiro' : 'openai';
},
normalizeTargetId(flowId, targetId, fallback = 'kiro-rs') {
const normalizedFlowId = this.normalizeFlowId(flowId);
if (normalizedFlowId !== 'kiro') {
return 'cpa';
}
const normalizedTargetId = String(targetId || '').trim().toLowerCase();
return normalizedTargetId === 'kiro-rs' ? normalizedTargetId : fallback;
},
},
GoPayUtils: {
normalizeGoPayCountryCode(value) {
const digits = String(value || '').replace(/\\D/g, '');
@@ -205,6 +232,9 @@ return {
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'gpc-helper'), 'gpc-helper');
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 '),
'https://gpc.qlhazycoder.top'
@@ -252,6 +282,11 @@ return {
assert.equal(api.normalizePersistentSettingValue('heroSmsPreferredPrice', '0.051234'), '0.0512');
assert.equal(api.normalizePersistentSettingValue('signupMethod', 'phone'), 'phone');
assert.equal(api.normalizePersistentSettingValue('signupMethod', 'unknown'), 'email');
assert.equal(api.normalizePersistentSettingValue('activeFlowId', 'codex'), 'openai');
assert.equal(api.normalizePersistentSettingValue('activeFlowId', 'kiro'), 'kiro');
assert.equal(api.normalizePersistentSettingValue('kiroTargetId', 'unknown'), 'kiro-rs');
assert.equal(api.normalizePersistentSettingValue('kiroRsUrl', ''), '');
assert.equal(api.normalizePersistentSettingValue('kiroRsKey', ' key-1 '), 'key-1');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', '5SIM'), '5sim');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'NEXSMS'), 'nexsms');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'unknown'), 'hero-sms');
@@ -102,7 +102,7 @@ test('account run history helper upgrades old records, keeps stopped items and s
attemptRun: 3,
},
plusModeEnabled: false,
contributionMode: false,
accountContributionEnabled: false,
});
const appended = await helpers.appendAccountRunRecord('node:fetch-login-code:failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
@@ -213,7 +213,7 @@ test('account run history helper accepts phone-only records without forcing emai
source: 'manual',
autoRunContext: null,
plusModeEnabled: false,
contributionMode: false,
accountContributionEnabled: false,
});
const normalized = helpers.normalizeAccountRunHistoryRecord({
@@ -426,22 +426,22 @@ test('account run history records preserve Plus and contribution mode flags', ()
email: 'plus@example.com',
password: 'secret',
plusModeEnabled: true,
contributionMode: true,
accountContributionEnabled: true,
}, 'success');
assert.equal(record.plusModeEnabled, true);
assert.equal(record.contributionMode, true);
assert.equal(record.accountContributionEnabled, true);
const normalized = helpers.normalizeAccountRunHistoryRecord({
email: 'plus@example.com',
password: 'secret',
finalStatus: 'success',
plusModeEnabled: true,
contributionMode: true,
accountContributionEnabled: true,
});
assert.equal(normalized.plusModeEnabled, true);
assert.equal(normalized.contributionMode, true);
assert.equal(normalized.accountContributionEnabled, true);
});
test('account run history helper clears persisted records and syncs full snapshot payload to local helper', async () => {
+150
View File
@@ -48,6 +48,16 @@ function extractFunction(name) {
return source.slice(start, end);
}
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 = `
const AUTH_CHAIN_NODE_IDS = new Set(['oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify']);
const STEP_NODE_IDS = {
@@ -460,6 +470,146 @@ return {
assert.match(events.logs[0].message, /授权后链总超时已关闭/);
});
test('oauth localhost timeout recovery resumes from bound-email relogin tail when present', async () => {
const events = {
logs: [],
recoveryRuns: [],
stateUpdates: [],
timeoutWindows: [],
};
const api = new Function('events', `
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const state = {
oauthUrl: 'https://oauth.example/current',
phoneSignupReloginAfterBindEmailEnabled: true,
};
const STEP_NODE_IDS = {
7: 'oauth-login',
8: 'fetch-login-code',
9: 'bind-email',
10: 'fetch-bind-email-code',
11: 'relogin-bound-email',
12: 'fetch-bound-email-login-code',
13: 'post-bound-email-phone-verification',
14: 'confirm-oauth',
15: 'platform-verify',
};
const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]));
function getErrorMessage(error) {
return error?.message || String(error || '');
}
function getAuthChainStartStepId() {
return 7;
}
function getStepIdByKeyForState(stepKey) {
return NODE_STEP_IDS[String(stepKey || '').trim()] || null;
}
function getNodeIdByStepForState(step) {
return STEP_NODE_IDS[Number(step)] || '';
}
function getStepIdByNodeIdForState(nodeId) {
return NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function getAutoRunWorkflowNodeIds() {
return [
'oauth-login',
'fetch-login-code',
'bind-email',
'fetch-bind-email-code',
'relogin-bound-email',
'fetch-bound-email-login-code',
'post-bound-email-phone-verification',
'confirm-oauth',
'platform-verify',
];
}
async function addLog(message, level = 'info', options = {}) {
events.logs.push({ message, level, options });
}
async function getLoginAuthStateFromContent() {
return { state: 'oauth_consent_page' };
}
function isAddPhoneAuthState() {
return false;
}
function getLoginAuthStateLabel(value) {
return value || 'unknown';
}
async function getState() {
return { ...state };
}
async function setState(update) {
events.stateUpdates.push(update);
Object.assign(state, update);
}
async function startOAuthFlowTimeoutWindow(payload) {
events.timeoutWindows.push(payload);
}
const step7Executor = {
async executeStep7(payload) {
events.recoveryRuns.push({ nodeId: 'oauth-login', visibleStep: payload.visibleStep });
},
};
const step8Executor = {
async executeStep8(payload) {
events.recoveryRuns.push({ nodeId: 'fetch-login-code', visibleStep: payload.visibleStep });
},
async executePostLoginPhoneVerification(payload) {
events.recoveryRuns.push({ nodeId: 'post-login-phone-verification', visibleStep: payload.visibleStep });
},
async executeBindEmail(payload) {
events.recoveryRuns.push({ nodeId: 'bind-email', visibleStep: payload.visibleStep });
},
async executeFetchBindEmailCode(payload) {
events.recoveryRuns.push({ nodeId: 'fetch-bind-email-code', visibleStep: payload.visibleStep });
},
async executeBoundEmailLoginCode(payload) {
events.recoveryRuns.push({ nodeId: 'fetch-bound-email-login-code', visibleStep: payload.visibleStep });
},
async executeBoundEmailPostLoginPhoneVerification(payload) {
events.recoveryRuns.push({ nodeId: 'post-bound-email-phone-verification', visibleStep: payload.visibleStep });
},
};
async function executeReloginBoundEmail(payload) {
events.recoveryRuns.push({ nodeId: 'relogin-bound-email', visibleStep: payload.visibleStep });
}
${extractFunction('isStep9OAuthLocalhostTimeoutError')}
${extractFunction('recoverOAuthLocalhostTimeout')}
return {
recoverOAuthLocalhostTimeout,
};
`)(events);
const recoveredState = await api.recoverOAuthLocalhostTimeout({
error: new Error('步骤 14:从拿到 OAuth 登录地址开始,5 分钟内未完成 OAuth localhost 回调,结束当前链路。'),
state: {
oauthUrl: 'https://oauth.example/current',
phoneSignupReloginAfterBindEmailEnabled: true,
},
visibleStep: 14,
});
assert.deepStrictEqual(events.recoveryRuns, [
{ nodeId: 'relogin-bound-email', visibleStep: 11 },
{ nodeId: 'fetch-bound-email-login-code', visibleStep: 12 },
{ nodeId: 'post-bound-email-phone-verification', visibleStep: 13 },
]);
assert.deepStrictEqual(events.stateUpdates, [{ localhostUrl: null }]);
assert.deepStrictEqual(events.timeoutWindows, [
{
step: 14,
oauthUrl: 'https://oauth.example/current',
},
]);
assert.equal(recoveredState.localhostUrl, null);
assert.ok(events.logs.some(({ message }) => /步骤 11/.test(message)));
assert.equal(events.recoveryRuns.some(({ nodeId }) => nodeId === 'oauth-login'), false);
});
test('executeNode retries fetch-network errors for fetch-signup-code with cooldown and bounded attempts', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
+1
View File
@@ -5,6 +5,7 @@ const fs = require('node:fs');
test('background imports auto-run controller module', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/auto-run-controller\.js/);
assert.match(source, /buildFreshAutoRunKeepState/);
});
test('auto-run controller module exposes a factory', () => {
+187 -166
View File
@@ -69,7 +69,7 @@ test('background imports contribution oauth module and keeps contribution runtim
assert.match(backgroundSource, /background\/contribution-oauth\.js/);
assert.doesNotMatch(persistedBlock, /contributionSessionId|contributionAuthUrl|contributionCallbackUrl|contributionStatus/);
assert.match(defaultStateBlock, /contributionMode:\s*false|CONTRIBUTION_RUNTIME_DEFAULTS/);
assert.match(defaultStateBlock, /accountContributionEnabled:\s*false|CONTRIBUTION_RUNTIME_DEFAULTS/);
});
test('contribution oauth module exposes a factory', () => {
@@ -84,14 +84,48 @@ test('contribution oauth module exposes a factory', () => {
assert.equal(Array.isArray(api?.RUNTIME_KEYS), true);
});
test('buildContributionModeState preserves active contribution runtime while keeping contribution on sub2api', () => {
const bundle = extractFunction(backgroundSource, 'buildContributionModeState');
test('buildAccountContributionState preserves active contribution runtime while keeping contribution on sub2api', () => {
const bundle = extractFunction(backgroundSource, 'buildAccountContributionState');
const helperBundle = [
'normalizeAccountContributionFlowId',
'normalizeAccountContributionAdapterId',
'assertAccountContributionAdapterAvailable',
'buildFlowContributionRuntimePatch',
'normalizeOpenAiContributionSource',
'resolveOpenAiContributionRoutingState',
].map((name) => extractFunction(backgroundSource, name)).join('\n');
const api = new Function(`
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const CONTRIBUTION_SOURCE_CPA = 'cpa';
const CONTRIBUTION_SOURCE_SUB2API = 'sub2api';
const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池';
const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus';
const self = {
MultiPageFlowRegistry: {
normalizeFlowId(value = '', fallback = 'openai') {
const normalized = String(value || '').trim().toLowerCase();
return normalized || fallback || 'openai';
},
},
MultiPageContributionRegistry: {
normalizeAdapterId(value = '') {
return String(value || '').trim().toLowerCase();
},
hasContributionAdapter(flowId, adapterId) {
return flowId === 'openai' && adapterId === 'openai-oauth';
},
getDefaultContributionAdapterId(flowId) {
return flowId === 'openai' ? 'openai-oauth' : '';
},
},
};
const DEFAULT_STATE = { panelMode: 'cpa' };
const CONTRIBUTION_RUNTIME_DEFAULTS = {
contributionMode: false,
contributionModeExpected: false,
accountContributionEnabled: false,
accountContributionExpected: false,
contributionAdapterId: '',
flowContributionRuntime: {},
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionNickname: '',
@@ -110,143 +144,130 @@ const CONTRIBUTION_RUNTIME_DEFAULTS = {
};
const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);
function isPlusModeState(state = {}) { return Boolean(state?.plusModeEnabled); }
function normalizeContributionModeSource(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'sub2api' ? 'sub2api' : 'cpa';
}
function resolveContributionModeRoutingState(state = {}) {
const currentStatus = String(state?.contributionStatus || '').trim().toLowerCase();
const currentSource = normalizeContributionModeSource(state?.contributionSource);
const hasActiveSession = Boolean(
String(state?.contributionSessionId || '').trim()
&& currentStatus
&& !['auto_approved', 'auto_rejected', 'expired', 'error'].includes(currentStatus)
);
if (hasActiveSession) {
return {
source: currentSource,
targetGroupName: currentSource === 'sub2api'
? (String(state?.contributionTargetGroupName || '').trim() || 'codex号池')
: '',
};
}
const source = 'sub2api';
return {
source,
targetGroupName: isPlusModeState(state)
? 'openai-plus'
: (String(state?.contributionTargetGroupName || '').trim() || 'codex号池'),
};
}
${helperBundle}
${bundle}
return { buildContributionModeState };
return { buildAccountContributionState };
`)();
assert.deepStrictEqual(
api.buildContributionModeState(true, {
panelMode: 'sub2api',
customPassword: 'Secret123!',
accountRunHistoryTextEnabled: true,
}, {
contributionSessionId: 'session-001',
contributionAuthUrl: 'https://auth.example.com',
contributionStatus: 'waiting',
contributionCallbackStatus: 'waiting',
}),
{
contributionMode: true,
contributionModeExpected: true,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionNickname: '',
contributionQq: '',
contributionSessionId: 'session-001',
contributionAuthUrl: 'https://auth.example.com',
contributionAuthState: '',
contributionCallbackUrl: '',
contributionStatus: 'waiting',
contributionStatusMessage: '',
contributionLastPollAt: 0,
contributionCallbackStatus: 'waiting',
contributionCallbackMessage: '',
contributionAuthOpenedAt: 0,
contributionAuthTabId: 0,
panelMode: 'sub2api',
customPassword: '',
accountRunHistoryTextEnabled: false,
}
);
const enabledState = api.buildAccountContributionState(true, {
panelMode: 'sub2api',
customPassword: 'Secret123!',
accountRunHistoryTextEnabled: true,
}, {
contributionSessionId: 'session-001',
contributionAuthUrl: 'https://auth.example.com',
contributionStatus: 'waiting',
contributionCallbackStatus: 'waiting',
});
assert.equal(enabledState.accountContributionEnabled, true);
assert.equal(enabledState.accountContributionExpected, true);
assert.equal(enabledState.contributionAdapterId, 'openai-oauth');
assert.deepStrictEqual(enabledState.flowContributionRuntime, {
openai: { enabled: true, adapterId: 'openai-oauth' },
});
assert.equal(enabledState.contributionSessionId, 'session-001');
assert.equal(enabledState.panelMode, 'sub2api');
assert.equal(enabledState.customPassword, '');
assert.equal(enabledState.accountRunHistoryTextEnabled, false);
assert.deepStrictEqual(
api.buildContributionModeState(false, {
panelMode: 'sub2api',
customPassword: 'Secret123!',
accountRunHistoryTextEnabled: true,
}, {
contributionSessionId: 'session-001',
contributionAuthUrl: 'https://auth.example.com',
contributionStatus: 'waiting',
}),
{
contributionMode: false,
contributionModeExpected: false,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionNickname: '',
contributionQq: '',
contributionSessionId: '',
contributionAuthUrl: '',
contributionAuthState: '',
contributionCallbackUrl: '',
contributionStatus: '',
contributionStatusMessage: '',
contributionLastPollAt: 0,
contributionCallbackStatus: 'idle',
contributionCallbackMessage: '',
contributionAuthOpenedAt: 0,
contributionAuthTabId: 0,
panelMode: 'sub2api',
customPassword: 'Secret123!',
accountRunHistoryTextEnabled: true,
}
);
const disabledState = api.buildAccountContributionState(false, {
panelMode: 'sub2api',
customPassword: 'Secret123!',
accountRunHistoryTextEnabled: true,
}, {
contributionSessionId: 'session-001',
contributionAuthUrl: 'https://auth.example.com',
contributionStatus: 'waiting',
});
assert.equal(disabledState.accountContributionEnabled, false);
assert.equal(disabledState.accountContributionExpected, false);
assert.equal(disabledState.contributionAdapterId, '');
assert.deepStrictEqual(disabledState.flowContributionRuntime, {});
assert.equal(disabledState.contributionSessionId, '');
assert.equal(disabledState.panelMode, 'sub2api');
assert.equal(disabledState.customPassword, 'Secret123!');
assert.deepStrictEqual(
api.buildContributionModeState(true, {
panelMode: 'cpa',
plusModeEnabled: true,
customPassword: 'Secret123!',
accountRunHistoryTextEnabled: true,
}, {}),
{
contributionMode: true,
contributionModeExpected: true,
contributionSource: 'sub2api',
contributionTargetGroupName: 'openai-plus',
contributionNickname: '',
contributionQq: '',
contributionSessionId: '',
contributionAuthUrl: '',
contributionAuthState: '',
contributionCallbackUrl: '',
contributionStatus: '',
contributionStatusMessage: '',
contributionLastPollAt: 0,
contributionCallbackStatus: 'idle',
contributionCallbackMessage: '',
contributionAuthOpenedAt: 0,
contributionAuthTabId: 0,
panelMode: 'sub2api',
customPassword: '',
accountRunHistoryTextEnabled: false,
}
);
const plusContributionState = api.buildAccountContributionState(true, {
panelMode: 'cpa',
plusModeEnabled: true,
customPassword: 'Secret123!',
accountRunHistoryTextEnabled: true,
}, {});
assert.equal(plusContributionState.contributionTargetGroupName, 'openai-plus');
assert.equal(plusContributionState.panelMode, 'sub2api');
});
test('resetState preserves contribution runtime across reset', () => {
assert.match(backgroundSource, /CONTRIBUTION_RUNTIME_KEYS/);
assert.match(backgroundSource, /const contributionModeState = buildContributionModeState/);
assert.match(backgroundSource, /\.\.\.contributionModeState/);
assert.match(backgroundSource, /const accountContributionState = buildAccountContributionState/);
assert.match(backgroundSource, /\.\.\.accountContributionState/);
});
test('storage migration upgrades legacy contribution mode into unified account contribution state', async () => {
const helperBundle = [
'normalizeAccountContributionFlowId',
'normalizeAccountContributionAdapterId',
'buildFlowContributionRuntimePatch',
].map((name) => extractFunction(backgroundSource, name)).join('\n');
const migrationBundle = extractFunction(backgroundSource, 'migrateLegacyAccountContributionState');
const api = new Function(`
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const self = {
MultiPageFlowRegistry: {
normalizeFlowId(value = '', fallback = 'openai') {
const normalized = String(value || '').trim().toLowerCase();
return normalized || fallback || 'openai';
},
},
MultiPageContributionRegistry: {
normalizeAdapterId(value = '') {
return String(value || '').trim().toLowerCase();
},
hasContributionAdapter(flowId, adapterId) {
return (flowId === 'openai' && adapterId === 'openai-oauth')
|| (flowId === 'kiro' && adapterId === 'kiro-builder-id');
},
getDefaultContributionAdapterId(flowId) {
return flowId === 'kiro' ? 'kiro-builder-id' : 'openai-oauth';
},
},
};
const sessionStore = {
contributionMode: true,
contributionModeExpected: true,
activeFlowId: 'kiro',
};
const removed = [];
const chrome = {
storage: {
session: {
async get() { return { ...sessionStore }; },
async set(updates) { Object.assign(sessionStore, updates); },
async remove(keys) { removed.push(['session', ...keys]); keys.forEach((key) => { delete sessionStore[key]; }); },
},
local: {
async remove(keys) { removed.push(['local', ...keys]); },
},
},
};
${helperBundle}
${migrationBundle}
return { migrateLegacyAccountContributionState, sessionStore, removed };
`)();
await api.migrateLegacyAccountContributionState();
assert.equal(api.sessionStore.accountContributionEnabled, true);
assert.equal(api.sessionStore.accountContributionExpected, true);
assert.equal(api.sessionStore.contributionAdapterId, 'kiro-builder-id');
assert.deepStrictEqual(api.sessionStore.flowContributionRuntime, {
kiro: { enabled: true, adapterId: 'kiro-builder-id' },
});
assert.equal(Object.prototype.hasOwnProperty.call(api.sessionStore, 'contributionMode'), false);
assert.deepStrictEqual(api.removed, [
['session', 'contributionMode', 'contributionModeExpected'],
['local', 'contributionMode', 'contributionModeExpected'],
]);
});
test('message router handles contribution mode, start flow, and status polling messages', async () => {
@@ -258,23 +279,23 @@ test('message router handles contribution mode, start flow, and status polling m
const router = api.createMessageRouter({
ensureManualInteractionAllowed: async () => ({
stepStatuses: { 1: 'pending', 2: 'completed' },
contributionMode: true,
accountContributionEnabled: true,
}),
pollContributionStatus: async (options) => {
calls.push({ type: 'poll', options });
return { contributionStatus: 'waiting' };
},
setContributionMode: async (enabled) => {
setAccountContributionMode: async (enabled) => {
calls.push({ type: 'toggle', enabled });
return {
contributionMode: Boolean(enabled),
accountContributionEnabled: Boolean(enabled),
panelMode: 'cpa',
};
},
startContributionFlow: async (options) => {
startFlowContribution: async (options) => {
calls.push({ type: 'start', options });
return {
contributionMode: true,
accountContributionEnabled: true,
contributionSessionId: 'session-001',
contributionStatus: 'started',
};
@@ -282,15 +303,15 @@ test('message router handles contribution mode, start flow, and status polling m
});
const enableResponse = await router.handleMessage({
type: 'SET_CONTRIBUTION_MODE',
type: 'SET_ACCOUNT_CONTRIBUTION_MODE',
payload: { enabled: true },
});
const startResponse = await router.handleMessage({
type: 'START_CONTRIBUTION_FLOW',
type: 'START_FLOW_CONTRIBUTION',
payload: { nickname: '阿青', qq: '123456' },
});
const pollResponse = await router.handleMessage({
type: 'POLL_CONTRIBUTION_STATUS',
type: 'POLL_FLOW_CONTRIBUTION_STATUS',
payload: { reason: 'test_poll' },
});
@@ -304,7 +325,7 @@ test('message router handles contribution mode, start flow, and status polling m
]);
});
test('message router re-syncs contribution mode before AUTO_RUN when sidepanel payload marks contributionMode=true', async () => {
test('message router re-syncs contribution mode before AUTO_RUN when sidepanel payload marks accountContributionEnabled=true', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
@@ -314,13 +335,13 @@ test('message router re-syncs contribution mode before AUTO_RUN when sidepanel p
clearStopRequest: () => {},
getPendingAutoRunTimerPlan: () => null,
getState: async () => ({
contributionMode: false,
accountContributionEnabled: false,
stepStatuses: {},
}),
normalizeRunCount: (value) => Number(value) || 1,
setContributionMode: async (enabled) => {
setAccountContributionMode: async (enabled) => {
calls.push({ type: 'toggle', enabled });
return { contributionMode: true };
return { accountContributionEnabled: true };
},
setState: async (updates) => {
calls.push({ type: 'setState', updates });
@@ -336,7 +357,7 @@ test('message router re-syncs contribution mode before AUTO_RUN when sidepanel p
totalRuns: 2,
autoRunSkipFailures: true,
mode: 'restart',
contributionMode: true,
accountContributionEnabled: true,
contributionNickname: '阿青',
contributionQq: '123456',
},
@@ -433,7 +454,7 @@ test('account run history snapshot sync is disabled in contribution mode', () =>
assert.equal(
helpers.shouldSyncAccountRunHistorySnapshot({
contributionMode: true,
accountContributionEnabled: true,
accountRunHistoryTextEnabled: true,
accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
}),
@@ -442,7 +463,7 @@ test('account run history snapshot sync is disabled in contribution mode', () =>
assert.equal(
helpers.shouldSyncAccountRunHistorySnapshot({
contributionMode: false,
accountContributionEnabled: false,
accountRunHistoryTextEnabled: true,
accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
}),
@@ -458,7 +479,7 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
const closeCallbackCalls = [];
let statusPollCount = 0;
let currentState = {
contributionMode: true,
accountContributionEnabled: true,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
email: 'user@example.com',
@@ -541,7 +562,7 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
},
});
const startedState = await manager.startContributionFlow();
const startedState = await manager.startFlowContribution();
assert.equal(startedState.contributionSessionId, 'session-001');
assert.equal(startedState.contributionAuthState, 'oauth-state-001');
assert.equal(startedState.contributionStatus, 'waiting');
@@ -577,7 +598,7 @@ test('contribution oauth manager deduplicates concurrent callback captures for t
let submitCallCount = 0;
let resolveSubmitRequest = null;
let currentState = {
contributionMode: true,
accountContributionEnabled: true,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionSessionId: 'session-001',
@@ -664,7 +685,7 @@ test('contribution oauth manager ignores tabs.onUpdated events without a real ur
const fetchCalls = [];
const addedListeners = {};
let currentState = {
contributionMode: true,
accountContributionEnabled: true,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionSessionId: 'session-001',
@@ -756,7 +777,7 @@ test('contribution oauth manager switches Plus contribution traffic to sub2api o
const globalScope = {};
const fetchCalls = [];
let currentState = {
contributionMode: true,
accountContributionEnabled: true,
plusModeEnabled: true,
contributionSource: 'sub2api',
contributionTargetGroupName: 'openai-plus',
@@ -817,7 +838,7 @@ test('contribution oauth manager switches Plus contribution traffic to sub2api o
},
});
await manager.startContributionFlow();
await manager.startFlowContribution();
assert.match(String(fetchCalls[0].options.body || ''), /"source":"sub2api"/);
assert.match(String(fetchCalls[0].options.body || ''), /"target_group_name":"openai-plus"/);
@@ -836,7 +857,7 @@ return { refreshOAuthUrlBeforeStep6 };
calls.push({ type: 'log', message, level, options });
};
globalThis.contributionOAuthManager = {
async startContributionFlow(options) {
async startFlowContribution(options) {
calls.push({ type: 'contribution', options });
return {
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-001',
@@ -854,7 +875,7 @@ return { refreshOAuthUrlBeforeStep6 };
globalThis.LOG_PREFIX = '[test]';
const oauthUrl = await api.refreshOAuthUrlBeforeStep6({
contributionMode: true,
accountContributionEnabled: true,
email: 'user@example.com',
});
@@ -862,7 +883,7 @@ return { refreshOAuthUrlBeforeStep6 };
assert.deepStrictEqual(calls, [
{
type: 'log',
message: 'contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...',
message: '账号贡献已开启,走公开贡献接口,正在申请 OAuth 登录地址...',
level: 'info',
options: { step: 7, stepKey: 'oauth-login' },
},
@@ -872,7 +893,7 @@ return { refreshOAuthUrlBeforeStep6 };
nickname: '',
openAuthTab: false,
stateOverride: {
contributionMode: true,
accountContributionEnabled: true,
email: 'user@example.com',
},
},
@@ -894,7 +915,7 @@ return { refreshOAuthUrlBeforeStep6 };
delete globalThis.LOG_PREFIX;
});
test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API/Codex2API path explicitly when contributionMode=false', async () => {
test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API/Codex2API path explicitly when accountContributionEnabled=false', async () => {
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
const calls = [];
@@ -907,7 +928,7 @@ return { refreshOAuthUrlBeforeStep6 };
calls.push({ type: 'log', message, level, options });
};
globalThis.contributionOAuthManager = {
async startContributionFlow() {
async startFlowContribution() {
calls.push({ type: 'contribution' });
return {
contributionAuthUrl: 'https://auth.example.com/oauth?state=unexpected',
@@ -925,7 +946,7 @@ return { refreshOAuthUrlBeforeStep6 };
globalThis.LOG_PREFIX = '[test]';
const oauthUrl = await api.refreshOAuthUrlBeforeStep6({
contributionMode: false,
accountContributionEnabled: false,
panelMode: 'sub2api',
email: 'user@example.com',
});
@@ -934,7 +955,7 @@ return { refreshOAuthUrlBeforeStep6 };
assert.deepStrictEqual(calls, [
{
type: 'log',
message: 'contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...',
message: '账号贡献未开启,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...',
level: 'info',
options: { step: 7, stepKey: 'oauth-login' },
},
@@ -956,7 +977,7 @@ return { refreshOAuthUrlBeforeStep6 };
delete globalThis.LOG_PREFIX;
});
test('executeStep10 blocks silent fallback when contributionModeExpected=true but contributionMode=false', async () => {
test('executeStep10 blocks silent fallback when accountContributionExpected=true but accountContributionEnabled=false', async () => {
const bundle = extractFunction(backgroundSource, 'executeStep10');
const api = new Function(`
@@ -973,10 +994,10 @@ return { executeStep10 };
await assert.rejects(
() => api.executeStep10({
contributionModeExpected: true,
contributionMode: false,
accountContributionExpected: true,
accountContributionEnabled: false,
}),
/步骤 10:当前自动流程预期使用贡献模式/
/步骤 10:当前自动流程预期使用账号贡献/
);
delete globalThis.executeContributionStep10;
+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'/);
});
@@ -0,0 +1,231 @@
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 i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
function createHarness() {
return new Function(`
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const workflowEngine = null;
const self = {
MultiPageStepDefinitions: {
getSteps(options = {}) {
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' },
{ 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: 'oauth-login' },
{ id: 7, key: 'fetch-login-code' },
{ id: 8, key: 'confirm-oauth' },
{ id: 9, key: 'platform-verify' },
];
},
getNodes(options = {}) {
return this.getSteps(options).map((definition) => ({
nodeId: String(definition.key || '').trim(),
displayOrder: Number(definition.id) || 0,
}));
},
},
};
function normalizeSignupMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email';
}
function normalizePlusPaymentMethod(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'gopay' || normalized === 'gpc-helper' ? normalized : 'paypal';
}
function normalizePlusAccountAccessStrategy(value = '') {
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 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,
effectiveSignupMethod: 'email',
stepDefinitionOptions: {
activeFlowId: 'openai',
panelMode: options.panelMode,
plusModeEnabled: Boolean(state.plusModeEnabled),
plusPaymentMethod: normalizePlusPaymentMethod(state.plusPaymentMethod),
plusAccountAccessStrategy: effectiveStrategy,
signupMethod: 'email',
},
};
}
${extractFunction('getSignupMethodForStepDefinitions')}
${extractFunction('buildResolvedStepDefinitionState')}
${extractFunction('getStepDefinitionsForState')}
${extractFunction('getNodeDefinitionsForState')}
${extractFunction('getNodeIdsForState')}
return {
buildResolvedStepDefinitionState,
getNodeIdsForState,
};
`)();
}
test('background step resolution keeps SUB2API session tail only when the effective Plus target supports it', () => {
const api = createHarness();
const state = {
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'sub2api',
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
};
const resolvedState = api.buildResolvedStepDefinitionState(state);
const nodeIds = api.getNodeIdsForState(state);
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 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 = {
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'cpa',
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
};
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',
]);
});
@@ -0,0 +1,23 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
const match = source.match(/function generatePassword\(\)\s*\{[\s\S]*?return passwordChars\.join\(''\);\r?\n\}/);
assert.ok(match, 'generatePassword definition should exist in background.js');
const generatePassword = new Function(`${match[0]}; return generatePassword;`)();
test('generatePassword produces shared account passwords within the required policy', () => {
for (let index = 0; index < 100; index += 1) {
const password = String(generatePassword() || '');
assert.ok(password.length >= 8, `password should be at least 8 characters: ${password}`);
assert.ok(password.length <= 64, `password should be at most 64 characters: ${password}`);
assert.match(password, /[A-Z]/, `password should include an uppercase letter: ${password}`);
assert.match(password, /[a-z]/, `password should include a lowercase letter: ${password}`);
assert.match(password, /[0-9]/, `password should include a digit: ${password}`);
assert.match(password, /[^A-Za-z0-9]/, `password should include a symbol: ${password}`);
}
});
@@ -0,0 +1,510 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadDesktopAuthorizeRunnerApi() {
const stateSource = fs.readFileSync('background/kiro/state.js', 'utf8');
const clientSource = fs.readFileSync('background/kiro/desktop-client.js', 'utf8');
const runnerSource = fs.readFileSync('background/kiro/desktop-authorize-runner.js', 'utf8');
const globalScope = {};
new Function('self', `${stateSource}; ${clientSource}; ${runnerSource}; return self;`)(globalScope);
return globalScope.MultiPageBackgroundKiroDesktopAuthorizeRunner;
}
function createDesktopAuthorizeState(overrides = {}) {
return {
kiroRuntime: {
session: {
desktopTabId: 91,
},
register: {
email: 'kiro-user@example.com',
},
desktopAuth: {
region: 'us-east-1',
clientId: 'desktop-client-id',
clientSecret: 'desktop-client-secret',
state: 'desktop-state-001',
codeVerifier: 'desktop-code-verifier',
redirectUri: 'http://127.0.0.1:43121/oauth/callback',
redirectPort: 43121,
authorizeUrl: 'https://example.com/authorize',
},
upload: {},
},
...overrides,
};
}
test('kiro desktop authorize runner exposes a factory and callback parser', () => {
const api = loadDesktopAuthorizeRunnerApi();
assert.equal(typeof api?.createKiroDesktopAuthorizeRunner, 'function');
assert.equal(typeof api?.parseDesktopCallbackUrl, 'function');
});
test('parseDesktopCallbackUrl validates state and redirect port', () => {
const api = loadDesktopAuthorizeRunnerApi();
const success = api.parseDesktopCallbackUrl(
'http://127.0.0.1:43121/oauth/callback?code=auth-code-001&state=state-001',
'state-001',
43121
);
assert.deepEqual(success, {
url: 'http://127.0.0.1:43121/oauth/callback?code=auth-code-001&state=state-001',
code: 'auth-code-001',
state: 'state-001',
});
const badState = api.parseDesktopCallbackUrl(
'http://127.0.0.1:43121/oauth/callback?code=auth-code-001&state=wrong-state',
'state-001',
43121
);
assert.equal(Object.prototype.hasOwnProperty.call(badState, 'code'), false);
assert.match(badState.error, /state/i);
const badPort = api.parseDesktopCallbackUrl(
'http://127.0.0.1:43122/oauth/callback?code=auth-code-001&state=state-001',
'state-001',
43121
);
assert.equal(badPort, null);
});
test('kiro desktop authorize runner uses a shared 3-minute page-load timeout budget', () => {
const source = fs.readFileSync('background/kiro/desktop-authorize-runner.js', 'utf8');
assert.match(source, /DEFAULT_KIRO_PAGE_LOAD_TIMEOUT_MS/);
assert.match(source, /createTimeoutBudget/);
assert.match(source, /resolveTimeoutBudget/);
assert.match(source, /timeoutBudget\.getRemainingMs\(1000\)/);
assert.match(source, /onRetryableError: buildDesktopRetryRecovery\(tabId, \{\s*\.\.\.options,\s*timeoutBudget,/);
assert.match(source, /awaitingCallbackAfterConsent/);
assert.match(source, /finalizeDesktopAuthorizeCallback/);
});
test('background wires the Kiro register injector into desktop authorization runner', () => {
const source = fs.readFileSync('background.js', 'utf8');
const start = source.indexOf('const kiroDesktopAuthorizeRunner = self.MultiPageBackgroundKiroDesktopAuthorizeRunner?.createKiroDesktopAuthorizeRunner({');
const end = source.indexOf('const kiroPublisher = self.MultiPageBackgroundKiroPublisherKiroRs?.createKiroRsPublisher({');
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const block = source.slice(start, end);
assert.match(block, /KIRO_REGISTER_INJECT_FILES/);
assert.match(block, /KIRO_DESKTOP_AUTHORIZE_INJECT_FILES/);
});
test('kiro desktop authorization opens account page when no web session tab is tracked', () => {
const source = fs.readFileSync('background/kiro/desktop-authorize-runner.js', 'utf8');
assert.match(source, /KIRO_WEB_ACCOUNT_URL = 'https:\/\/app\.kiro\.dev\/settings\/account'/);
assert.match(source, /chrome\.tabs\.create\(\{\s*url: KIRO_WEB_ACCOUNT_URL,\s*active: true,/);
assert.match(source, /未能从已打开页面确认 Kiro Web 登录态,正在打开 Kiro 账号页重新确认/);
});
test('kiro desktop authorization is gated by completed Kiro Web sign-in', () => {
const source = fs.readFileSync('background/kiro/desktop-authorize-runner.js', 'utf8');
assert.match(source, /restoreKiroWebSessionFromOpenTabs/);
assert.match(source, /GET_KIRO_REGISTER_PAGE_STATE/);
assert.match(source, /Kiro Web 登录态尚未建立/);
assert.match(source, /已检测到 Kiro Web 登录态/);
});
test('executeKiroStartDesktopAuthorize restores existing Kiro Web session before desktop auth', async () => {
const api = loadDesktopAuthorizeRunnerApi();
let currentState = {
kiroRuntime: {
session: {},
register: {},
webAuth: {},
desktopAuth: {},
upload: {},
},
};
const setStateCalls = [];
const logs = [];
let completedPayload = null;
let openedAuthorizeUrl = '';
let registerReadySource = '';
let desktopRegisteredTabId = null;
const runner = api.createKiroDesktopAuthorizeRunner({
addLog: async (message, level) => {
logs.push({ message, level });
},
chrome: {
tabs: {
get: async (tabId) => ({
id: tabId,
status: 'complete',
url: tabId === 77
? 'https://app.kiro.dev/settings/account'
: 'https://oidc.us-east-1.amazonaws.com/authorize',
}),
query: async (queryInfo) => {
if (Array.isArray(queryInfo.url)) {
return [{
id: 77,
status: 'complete',
url: 'https://app.kiro.dev/settings/account',
}];
}
return [];
},
update: async () => ({}),
},
webNavigation: {
onBeforeNavigate: { addListener: () => {} },
onCommitted: { addListener: () => {} },
},
webRequest: {
onBeforeRequest: { addListener: () => {} },
},
},
completeNodeFromBackground: async (_nodeId, payload) => {
completedPayload = payload;
},
ensureContentScriptReadyOnTab: async (source, tabId, options = {}) => {
assert.equal(tabId, 77);
registerReadySource = source;
assert.equal(options.injectSource, 'kiro-register-page');
},
fetchImpl: async () => ({
ok: true,
text: async () => JSON.stringify({
clientId: 'restored-client-id',
clientSecret: 'restored-client-secret',
}),
}),
getState: async () => currentState,
getTabId: async () => null,
isTabAlive: async () => false,
KIRO_REGISTER_INJECT_FILES: ['content/kiro/register-page.js'],
registerTab: async (source, tabId) => {
if (source === 'kiro-desktop-authorize') {
desktopRegisteredTabId = tabId;
}
},
reuseOrCreateTab: async (_source, url) => {
openedAuthorizeUrl = url;
return 88;
},
sendToContentScriptResilient: async (source, message) => {
assert.equal(source, 'kiro-register-page');
assert.equal(message.type, 'GET_KIRO_REGISTER_PAGE_STATE');
return {
ok: true,
state: 'kiro_web_signed_in',
url: 'https://app.kiro.dev/settings/account',
accountEmail: 'restored@duck.com',
};
},
setState: async (patch) => {
setStateCalls.push(patch);
currentState = { ...currentState, ...patch };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabStableComplete: async () => ({ status: 'complete' }),
});
await runner.executeKiroStartDesktopAuthorize(currentState);
assert.equal(registerReadySource, 'kiro-register-page');
assert.equal(desktopRegisteredTabId, 88);
assert.match(openedAuthorizeUrl, /client_id=restored-client-id/);
assert.equal(currentState.email, 'restored@duck.com');
assert.equal(currentState.kiroRuntime.register.email, 'restored@duck.com');
assert.equal(currentState.kiroRuntime.register.status, 'completed');
assert.equal(currentState.kiroRuntime.webAuth.status, 'signed_in');
assert.equal(completedPayload?.kiroRuntime?.desktopAuth?.clientId, 'restored-client-id');
assert.equal(
logs.some(({ message }) => message.includes('检测到已有 Kiro Web 登录态,已恢复账号 restored@duck.com')),
true
);
assert.equal(setStateCalls.length >= 2, true);
});
test('executeKiroStartDesktopAuthorize opens Kiro account page to restore login state', async () => {
const api = loadDesktopAuthorizeRunnerApi();
let currentState = {
kiroRuntime: {
session: {},
register: {},
webAuth: {},
desktopAuth: {},
upload: {},
},
};
const logs = [];
const registeredTabs = [];
let createdTabUrl = '';
let completedPayload = null;
const runner = api.createKiroDesktopAuthorizeRunner({
addLog: async (message, level) => {
logs.push({ message, level });
},
chrome: {
tabs: {
create: async (payload) => {
createdTabUrl = payload.url;
return {
id: 91,
status: 'loading',
url: payload.url,
};
},
get: async (tabId) => ({
id: tabId,
status: 'complete',
url: tabId === 91
? 'https://app.kiro.dev/settings/account'
: 'https://oidc.us-east-1.amazonaws.com/authorize',
}),
query: async () => [],
update: async () => ({}),
},
webNavigation: {
onBeforeNavigate: { addListener: () => {} },
onCommitted: { addListener: () => {} },
},
webRequest: {
onBeforeRequest: { addListener: () => {} },
},
},
completeNodeFromBackground: async (_nodeId, payload) => {
completedPayload = payload;
},
ensureContentScriptReadyOnTab: async (source, tabId, options = {}) => {
assert.equal(source, 'kiro-register-page');
assert.equal(tabId, 91);
assert.deepEqual(options.inject, ['content/kiro/register-page.js']);
assert.equal(options.injectSource, 'kiro-register-page');
},
fetchImpl: async () => ({
ok: true,
text: async () => JSON.stringify({
clientId: 'opened-client-id',
clientSecret: 'opened-client-secret',
}),
}),
getState: async () => currentState,
getTabId: async () => null,
isTabAlive: async () => false,
KIRO_REGISTER_INJECT_FILES: ['content/kiro/register-page.js'],
registerTab: async (source, tabId) => {
registeredTabs.push({ source, tabId });
},
reuseOrCreateTab: async (_source, url) => {
assert.match(url, /oidc\.us-east-1\.amazonaws\.com\/authorize/);
return 92;
},
sendToContentScriptResilient: async (source, message) => {
assert.equal(source, 'kiro-register-page');
assert.equal(message.type, 'GET_KIRO_REGISTER_PAGE_STATE');
return {
ok: true,
state: 'kiro_web_signed_in',
url: 'https://app.kiro.dev/settings/account',
accountEmail: 'opened@duck.com',
};
},
setState: async (patch) => {
currentState = { ...currentState, ...patch };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabStableComplete: async () => ({ status: 'complete' }),
});
await runner.executeKiroStartDesktopAuthorize(currentState);
assert.equal(createdTabUrl, 'https://app.kiro.dev/settings/account');
assert.equal(currentState.kiroRuntime.register.email, 'opened@duck.com');
assert.equal(completedPayload?.kiroRuntime?.desktopAuth?.clientId, 'opened-client-id');
assert.deepEqual(registeredTabs, [
{ source: 'kiro-register-page', tabId: 91 },
{ source: 'kiro-desktop-authorize', tabId: 92 },
]);
assert.equal(
logs.some(({ message }) => message.includes('正在打开 Kiro 账号页重新确认')),
true
);
});
test('executeKiroCompleteDesktopAuthorize finishes from callback page without waiting for tracker replay', async () => {
const api = loadDesktopAuthorizeRunnerApi();
let currentState = createDesktopAuthorizeState();
let completedPayload = null;
const runner = api.createKiroDesktopAuthorizeRunner({
addLog: async () => {},
chrome: {
tabs: {
get: async (tabId) => ({
id: tabId,
status: 'complete',
url: 'http://127.0.0.1:43121/oauth/callback?code=auth-code-001&state=desktop-state-001',
}),
remove: async () => {},
update: async () => ({}),
},
webNavigation: {
onBeforeNavigate: { addListener: () => {} },
onCommitted: { addListener: () => {} },
},
webRequest: {
onBeforeRequest: { addListener: () => {} },
},
},
completeNodeFromBackground: async (_nodeId, payload) => {
completedPayload = payload;
},
ensureContentScriptReadyOnTab: async () => {},
fetchImpl: async () => ({
ok: true,
text: async () => JSON.stringify({
accessToken: 'access-token-001',
refreshToken: 'refresh-token-001',
}),
}),
getState: async () => currentState,
getTabId: async () => 91,
isTabAlive: async () => true,
registerTab: async () => {},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'GET_KIRO_DESKTOP_AUTHORIZE_STATE') {
return {
state: 'callback_page',
url: 'http://127.0.0.1:43121/oauth/callback?code=auth-code-001&state=desktop-state-001',
};
}
throw new Error(`Unexpected message: ${message.type}`);
},
setState: async (patch) => {
currentState = { ...currentState, ...patch };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabStableComplete: async () => ({ status: 'complete' }),
});
await runner.executeKiroCompleteDesktopAuthorize(currentState);
assert.equal(completedPayload?.kiroRuntime?.desktopAuth?.authorizationCode, 'auth-code-001');
assert.equal(completedPayload?.kiroRuntime?.desktopAuth?.refreshToken, 'refresh-token-001');
});
test('executeKiroCompleteDesktopAuthorize waits for callback after consent even if original tab disappears', async () => {
const api = loadDesktopAuthorizeRunnerApi();
let currentState = createDesktopAuthorizeState({
kiroRuntime: {
session: {
desktopTabId: 91,
},
register: {
email: 'kiro-user@example.com',
},
desktopAuth: {
region: 'us-east-1',
clientId: 'desktop-client-id',
clientSecret: 'desktop-client-secret',
state: 'desktop-state-002',
codeVerifier: 'desktop-code-verifier',
redirectUri: 'http://127.0.0.1:43121/oauth/callback',
redirectPort: 43121,
authorizeUrl: 'https://example.com/authorize',
},
upload: {},
},
});
let completedPayload = null;
let tabAlive = true;
let navigationListener = null;
const runner = api.createKiroDesktopAuthorizeRunner({
addLog: async () => {},
chrome: {
tabs: {
get: async (tabId) => {
if (!tabAlive) {
throw new Error(`No tab with id: ${tabId}`);
}
return {
id: tabId,
status: 'complete',
url: 'https://example.com/consent',
};
},
remove: async () => {},
update: async (tabId) => {
if (!tabAlive) {
throw new Error(`No tab with id: ${tabId}`);
}
return { id: tabId };
},
},
webNavigation: {
onBeforeNavigate: { addListener: () => {} },
onCommitted: {
addListener: (listener) => {
navigationListener = listener;
},
},
},
webRequest: {
onBeforeRequest: { addListener: () => {} },
},
},
completeNodeFromBackground: async (_nodeId, payload) => {
completedPayload = payload;
},
ensureContentScriptReadyOnTab: async () => {},
fetchImpl: async () => ({
ok: true,
text: async () => JSON.stringify({
accessToken: 'access-token-002',
refreshToken: 'refresh-token-002',
}),
}),
getState: async () => currentState,
getTabId: async () => 91,
isTabAlive: async () => true,
registerTab: async () => {},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'GET_KIRO_DESKTOP_AUTHORIZE_STATE') {
return {
state: 'consent_page',
url: 'https://example.com/consent',
};
}
if (message.type === 'EXECUTE_KIRO_DESKTOP_AUTHORIZE_ACTION') {
assert.equal(message.payload?.action, 'confirm-consent');
tabAlive = false;
setTimeout(() => {
navigationListener?.({
url: 'http://127.0.0.1:43121/oauth/callback?code=auth-code-002&state=desktop-state-002',
tabId: 91,
});
}, 50);
return {
submitted: true,
state: 'consent_submitted',
url: 'https://example.com/consent',
};
}
throw new Error(`Unexpected message: ${message.type}`);
},
setState: async (patch) => {
currentState = { ...currentState, ...patch };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
waitForTabStableComplete: async () => ({ status: 'complete' }),
});
await runner.executeKiroCompleteDesktopAuthorize(currentState);
assert.equal(completedPayload?.kiroRuntime?.desktopAuth?.authorizationCode, 'auth-code-002');
assert.equal(completedPayload?.kiroRuntime?.desktopAuth?.refreshToken, 'refresh-token-002');
});
@@ -0,0 +1,267 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadPublisherApi() {
const stateSource = fs.readFileSync('background/kiro/state.js', 'utf8');
const publisherSource = fs.readFileSync('background/kiro/publisher-kiro-rs.js', 'utf8');
const globalScope = {};
new Function('self', `${stateSource}; ${publisherSource}; return self;`)(globalScope);
return globalScope.MultiPageBackgroundKiroPublisherKiroRs;
}
test('kiro publisher exposes a factory and upload payload helpers', () => {
const api = loadPublisherApi();
assert.equal(typeof api?.createKiroRsPublisher, 'function');
assert.equal(typeof api?.buildKiroRsPayload, 'function');
assert.equal(typeof api?.buildMachineId, 'function');
});
test('kiro publisher builds kiro.rs payload from desktop auth runtime with BuilderId profileArn', async () => {
const api = loadPublisherApi();
const payload = api.buildKiroRsPayload({
kiroTargetId: 'kiro-rs',
kiroRsUrl: 'https://kiro.example.com/admin',
kiroRsKey: 'demo-key',
ipProxyEnabled: true,
ipProxyHost: '1.2.3.4',
ipProxyPort: '8080',
ipProxyProtocol: 'http',
ipProxyUsername: 'proxy-user',
ipProxyPassword: 'proxy-pass',
kiroRuntime: {
register: {
email: 'aws-user@example.com',
},
desktopAuth: {
region: 'us-east-1',
clientId: 'client-001',
clientSecret: 'secret-001',
refreshToken: 'refresh-token-001',
},
upload: {
targetId: 'kiro-rs',
},
},
});
const machineId = await api.buildMachineId('refresh-token-001');
assert.deepEqual(payload, {
targetId: 'kiro-rs',
region: 'us-east-1',
email: 'aws-user@example.com',
refreshToken: 'refresh-token-001',
profileArn: 'arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX',
clientId: 'client-001',
clientSecret: 'secret-001',
authMethod: 'idc',
authRegion: 'us-east-1',
apiRegion: 'us-east-1',
proxyUrl: 'http://1.2.3.4:8080',
proxyUsername: 'proxy-user',
proxyPassword: 'proxy-pass',
});
assert.equal(machineId.length, 64);
assert.match(machineId, /^[0-9a-f]{64}$/);
});
test('kiro publisher reads latest kiro.rs key from background state instead of stale node snapshot', async () => {
const api = loadPublisherApi();
const requests = [];
let liveState = {
kiroTargetId: 'kiro-rs',
kiroRsUrl: 'https://kiro.example.com/admin',
kiroRsKey: 'live-key',
email: 'aws-user@example.com',
kiroRuntime: {
register: {
email: 'aws-user@example.com',
},
desktopAuth: {
region: 'us-east-1',
clientId: 'client-001',
clientSecret: 'secret-001',
refreshToken: 'refresh-token-001',
},
upload: {
targetId: 'kiro-rs',
},
},
settingsState: {
flows: {
kiro: {
targetId: 'kiro-rs',
targets: {
'kiro-rs': {
baseUrl: 'https://kiro.example.com/admin',
apiKey: 'live-key',
},
},
},
},
},
};
const completed = [];
const publisher = api.createKiroRsPublisher({
addLog: async () => {},
completeNodeFromBackground: async (nodeId, payload) => {
completed.push({ nodeId, payload });
},
fetchImpl: async (url, options = {}) => {
requests.push({
url,
method: options.method || 'GET',
apiKey: options.headers?.['x-api-key'],
authorization: options.headers?.Authorization,
body: options.body ? JSON.parse(options.body) : null,
});
if ((options.method || 'GET') === 'GET') {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify({ items: [] }),
};
}
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify({
message: 'Credential uploaded.',
credentialId: 9,
email: 'aws-user@example.com',
}),
};
},
getState: async () => ({ ...liveState }),
setState: async (updates = {}) => {
liveState = { ...liveState, ...updates };
},
});
await publisher.executeKiroUploadCredential({
nodeId: 'kiro-upload-credential',
kiroRsKey: '',
settingsState: {
flows: {
kiro: {
targetId: 'kiro-rs',
targets: {
'kiro-rs': {
baseUrl: 'https://kiro.example.com/admin',
apiKey: '',
},
},
},
},
},
});
assert.equal(requests.length, 2);
assert.equal(requests[0].apiKey, 'live-key');
assert.equal(requests[0].authorization, 'Bearer live-key');
assert.equal(requests[1].apiKey, 'live-key');
assert.equal(requests[1].authorization, 'Bearer live-key');
assert.equal(
requests[1].body.profileArn,
'arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX'
);
assert.equal(completed.length, 1);
assert.equal(completed[0].nodeId, 'kiro-upload-credential');
});
test('kiro publisher routes step 9 through public contribution upload when contribution mode is enabled', async () => {
const api = loadPublisherApi();
const requests = [];
const completed = [];
let liveState = {
activeFlowId: 'kiro',
flowId: 'kiro',
accountContributionEnabled: true,
contributionAdapterId: 'kiro-builder-id',
kiroTargetId: 'kiro-rs',
kiroRuntime: {
register: {
email: 'aws-user@example.com',
},
desktopAuth: {
region: 'us-east-1',
clientId: 'client-001',
clientSecret: 'secret-001',
refreshToken: 'refresh-token-001',
},
upload: {
targetId: 'kiro-rs',
},
},
};
const publisher = api.createKiroRsPublisher({
addLog: async () => {},
completeNodeFromBackground: async (nodeId, payload) => {
completed.push({ nodeId, payload });
},
fetchImpl: async (url, options = {}) => {
requests.push({ url, options });
throw new Error('kiro.rs upload should not be called in contribution mode');
},
getState: async () => ({ ...liveState }),
maybeSubmitFlowContribution: async (_state, options = {}) => ({
ok: true,
skipped: false,
contributionId: 'kiro-contribution-009',
message: `贡献链路成功:${options.trigger || 'unknown'}`,
}),
setState: async (updates = {}) => {
liveState = { ...liveState, ...updates };
},
});
await publisher.executeKiroUploadCredential({
nodeId: 'kiro-upload-credential',
});
assert.equal(requests.length, 0);
assert.equal(completed.length, 1);
assert.equal(completed[0].nodeId, 'kiro-upload-credential');
assert.equal(completed[0].payload.kiroRuntime.upload.targetId, 'contribution');
assert.equal(completed[0].payload.kiroRuntime.upload.status, 'uploaded');
assert.equal(completed[0].payload.kiroRuntime.upload.credentialId, 'kiro-contribution-009');
assert.equal(completed[0].payload.kiroRuntime.upload.lastMessage, '贡献链路成功:kiro-step-9');
});
test('kiro publisher trims api key and includes fallback Authorization header during connection check', async () => {
const api = loadPublisherApi();
const requests = [];
const result = await api.checkKiroRsConnection(
'https://kiro.example.com/admin',
' live-key ',
async (url, options = {}) => {
requests.push({
url,
method: options.method || 'GET',
apiKey: options.headers?.['x-api-key'],
authorization: options.headers?.Authorization,
});
return {
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => JSON.stringify({
error: {
message: 'Invalid or missing admin API key',
},
}),
};
}
);
assert.equal(requests.length, 1);
assert.equal(requests[0].method, 'GET');
assert.equal(requests[0].apiKey, 'live-key');
assert.equal(requests[0].authorization, 'Bearer live-key');
assert.equal(result.ok, false);
assert.equal(result.status, 401);
assert.equal(result.message, 'kiro.rs API Key 被拒绝(HTTP 401:Invalid or missing admin API key)');
});
@@ -0,0 +1,41 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadRegisterRunnerApi() {
const stateSource = fs.readFileSync('background/kiro/state.js', 'utf8');
const runnerSource = fs.readFileSync('background/kiro/register-runner.js', 'utf8');
const globalScope = {};
new Function('self', `${stateSource}; ${runnerSource}; return self;`)(globalScope);
return globalScope.MultiPageBackgroundKiroRegisterRunner;
}
test('kiro register runner module exposes a factory and Kiro official sign-in entry', () => {
const api = loadRegisterRunnerApi();
assert.equal(typeof api?.createKiroRegisterRunner, 'function');
assert.equal(api?.KIRO_SIGNIN_URL, 'https://app.kiro.dev/signin');
});
test('kiro register runner removed the old AWS device authorization bootstrap', () => {
const source = fs.readFileSync('background/kiro/register-runner.js', 'utf8');
assert.doesNotMatch(source, /startBuilderIdDeviceLogin/);
assert.doesNotMatch(source, /device_authorization/);
assert.doesNotMatch(source, /verificationUriComplete/);
assert.match(source, /https:\/\/app\.kiro\.dev\/signin/);
});
test('kiro register runner uses a shared 3-minute page-load timeout budget', () => {
const source = fs.readFileSync('background/kiro/register-runner.js', 'utf8');
assert.match(source, /DEFAULT_KIRO_PAGE_LOAD_TIMEOUT_MS/);
assert.match(source, /createTimeoutBudget/);
assert.match(source, /resolveTimeoutBudget/);
assert.match(source, /timeoutBudget\.getRemainingMs\(1000\)/);
assert.match(source, /onRetryableError: buildKiroRetryRecovery\(tabId, \{\s*\.\.\.options,\s*timeoutBudget,/);
});
test('kiro register consent step treats Kiro Web signed-in page as completion', () => {
const source = fs.readFileSync('background/kiro/register-runner.js', 'utf8');
assert.match(source, /targetStates: \['authorization_page', 'kiro_web_signed_in'\]/);
assert.match(source, /landingResult\?\.state !== 'kiro_web_signed_in'/);
assert.doesNotMatch(source, /landingResult\?\.state !== 'success_page'/);
});
+146
View File
@@ -0,0 +1,146 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadKiroStateApi() {
const source = fs.readFileSync('background/kiro/state.js', 'utf8');
const globalScope = {};
return new Function('self', `${source}; return self.MultiPageBackgroundKiroState;`)(globalScope);
}
test('background imports kiro state module and routes Kiro runtime through dedicated helpers', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/kiro\/state\.js/);
assert.match(source, /const kiroStateHelpers = self\.MultiPageBackgroundKiroState/);
assert.match(source, /kiroStateHelpers\?\.buildStateView/);
assert.match(source, /kiroStateHelpers\?\.buildSessionStatePatch/);
assert.match(source, /kiroStateHelpers\?\.buildDownstreamResetPatch/);
assert.match(source, /kiroStateHelpers\?\.applyNodeCompletionPayload/);
});
test('kiro state module exposes canonical nested kiroRuntime view', () => {
const api = loadKiroStateApi();
const view = api.buildStateView({
kiroTargetId: 'kiro-rs',
kiroRuntime: {
session: {
currentStage: 'desktop-authorize',
registerTabId: 88,
pageState: 'name_entry',
pageUrl: 'https://view.awsapps.com/start',
},
register: {
email: 'aws-user@example.com',
fullName: 'Ada Lovelace',
loginUrl: 'https://app.kiro.dev/signin',
status: 'waiting_name',
},
webAuth: {
status: 'signin_started',
hasAccessToken: false,
hasSessionToken: false,
},
desktopAuth: {
clientId: 'client-001',
clientSecret: 'secret-001',
refreshToken: 'refresh-001',
status: 'authorized',
},
upload: {
targetId: 'kiro-rs',
status: 'ready_to_upload',
credentialId: 321,
},
},
});
assert.equal(view.kiroTargetId, 'kiro-rs');
assert.equal(view.kiroRuntime.session.currentStage, 'desktop-authorize');
assert.equal(view.kiroRuntime.session.registerTabId, 88);
assert.equal(view.kiroRuntime.register.email, 'aws-user@example.com');
assert.equal(view.kiroRuntime.register.loginUrl, 'https://app.kiro.dev/signin');
assert.equal(view.kiroRuntime.webAuth.status, 'signin_started');
assert.equal(view.kiroRuntime.desktopAuth.clientId, 'client-001');
assert.equal(view.kiroRuntime.desktopAuth.refreshToken, 'refresh-001');
assert.equal(view.kiroRuntime.upload.status, 'ready_to_upload');
assert.equal(view.kiroRuntime.upload.credentialId, 321);
});
test('kiro state session patch accepts canonical nested runtime updates', () => {
const api = loadKiroStateApi();
const patch = api.buildSessionStatePatch({
kiroRuntime: api.buildDefaultRuntimeState(),
}, {
kiroRuntime: {
session: {
currentStage: 'register',
pageState: 'otp_page',
pageUrl: 'https://signin.aws/register',
},
register: {
email: 'aws-user@example.com',
fullName: 'Ada Lovelace',
verificationRequestedAt: 1700000000000,
},
desktopAuth: {
status: 'waiting_callback',
},
upload: {
status: 'waiting_register',
},
},
});
assert.equal(patch.kiroRuntime.session.currentStage, 'register');
assert.equal(patch.kiroRuntime.session.pageState, 'otp_page');
assert.equal(patch.kiroRuntime.session.pageUrl, 'https://signin.aws/register');
assert.equal(patch.kiroRuntime.register.email, 'aws-user@example.com');
assert.equal(patch.kiroRuntime.register.fullName, 'Ada Lovelace');
assert.equal(patch.kiroRuntime.register.verificationRequestedAt, 1700000000000);
assert.equal(patch.kiroRuntime.desktopAuth.status, 'waiting_callback');
assert.equal(patch.kiroRuntime.upload.status, 'waiting_register');
});
test('kiro state reset helpers clear downstream runtime and fresh keep-state preserves only target selection', () => {
const api = loadKiroStateApi();
const currentState = {
kiroTargetId: 'kiro-rs',
kiroRuntime: {
session: {
currentStage: 'upload',
registerTabId: 88,
},
register: {
email: 'aws-user@example.com',
fullName: 'Ada Lovelace',
status: 'completed',
},
desktopAuth: {
clientId: 'client-001',
clientSecret: 'secret-001',
refreshToken: 'refresh-001',
status: 'authorized',
},
upload: {
targetId: 'kiro-rs',
status: 'uploaded',
credentialId: 321,
},
},
};
const resetPatch = api.buildDownstreamResetPatch('kiro-submit-email', currentState);
assert.equal(resetPatch.kiroRuntime.session.currentStage, 'register');
assert.equal(resetPatch.kiroRuntime.register.email, '');
assert.equal(resetPatch.kiroRuntime.register.fullName, '');
assert.equal(resetPatch.kiroRuntime.desktopAuth.refreshToken, '');
assert.equal(resetPatch.kiroRuntime.upload.status, '');
assert.equal(resetPatch.kiroRuntime.upload.credentialId, null);
const keepState = api.buildFreshKeepState(currentState);
assert.equal(keepState.kiroTargetId, 'kiro-rs');
assert.equal(keepState.kiroRuntime.register.email, '');
assert.equal(keepState.kiroRuntime.desktopAuth.refreshToken, '');
assert.equal(keepState.kiroRuntime.upload.status, '');
assert.equal(keepState.kiroRuntime.upload.targetId, 'kiro-rs');
});
@@ -50,4 +50,16 @@ test('logging/status add-phone detection ignores step 2 phone-entry switch failu
loggingStatus.getErrorMessage(new Error('GPC_TASK_ENDED::GPC OTP 超时,请重新创建任务')),
'GPC OTP 超时,请重新创建任务'
);
assert.equal(
loggingStatus.isKiroProxyFailure('Kiro 注册页出现 AWS 请求异常,通常是当前代理 IP 或出口区域异常,请先切换代理后再重试。'),
true
);
assert.equal(
loggingStatus.isKiroProxyFailure('Kiro 注册页返回 403(CloudFront 拒绝请求),通常是当前代理 IP 或区域触发了 AWS 风控,请更换代理后重试。'),
true
);
assert.equal(
loggingStatus.isKiroProxyFailure('步骤 2:邮箱已提交,当前已进入姓名页。'),
false
);
});
+2 -2
View File
@@ -674,7 +674,7 @@ return {
test('resetState preserves LuckMail session config, used map, and preserve tag cache while clearing runtime purchase state', async () => {
const bundle = [
extractFunction('buildContributionModeState'),
extractFunction('buildAccountContributionState'),
extractFunction('resetState'),
].join('\n');
@@ -702,7 +702,7 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
' email: null,',
'};',
'const CONTRIBUTION_RUNTIME_DEFAULTS = {',
' contributionMode: false,',
' accountContributionEnabled: false,',
" contributionSessionId: '',",
" contributionAuthUrl: '',",
" contributionAuthState: '',",
+595 -7
View File
@@ -120,7 +120,7 @@ test('SAVE_SETTING broadcasts free phone reuse setting updates for realtime side
},
broadcastDataUpdate: (payload) => broadcasts.push(payload),
getState: async () => ({ ...state }),
setPersistentSettings: async () => {},
setPersistentSettings: async (updates) => ({ ...updates }),
setState: async (updates) => {
state = { ...state, ...updates };
},
@@ -181,6 +181,7 @@ test('SAVE_SETTING preserves phone reuse preferences while phone signup is selec
getState: async () => ({ ...state }),
setPersistentSettings: async (updates) => {
persistedPayloads.push({ ...updates });
return { ...updates };
},
setState: async (updates) => {
state = { ...state, ...updates };
@@ -239,7 +240,7 @@ test('SAVE_SETTING allows phone reuse preferences after switching back to email
}),
broadcastDataUpdate: () => {},
getState: async () => ({ ...state }),
setPersistentSettings: async () => {},
setPersistentSettings: async (updates) => ({ ...updates }),
setState: async (updates) => {
state = { ...state, ...updates };
},
@@ -276,11 +277,11 @@ test('SAVE_SETTING broadcasts operation delay setting without background success
addLog: async (message, level = 'info') => logs.push({ message, level }),
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'operationDelayEnabled')
? { operationDelayEnabled: input.operationDelayEnabled === false ? false : true }
? { operationDelayEnabled: true }
: {},
broadcastDataUpdate: (payload) => broadcasts.push(payload),
getState: async () => ({ ...state }),
setPersistentSettings: async () => {},
setPersistentSettings: async (updates) => ({ ...updates }),
setState: async (updates) => { state = { ...state, ...updates }; },
});
@@ -291,11 +292,597 @@ test('SAVE_SETTING broadcasts operation delay setting without background success
});
assert.equal(response.ok, true);
assert.equal(state.operationDelayEnabled, false);
assert.deepStrictEqual(broadcasts.at(-1), { operationDelayEnabled: false });
assert.equal(state.operationDelayEnabled, true);
assert.deepStrictEqual(broadcasts.at(-1), { operationDelayEnabled: true });
assert.equal(logs.length, 0);
});
test('SAVE_SETTING rebuilds Plus node statuses when the account access strategy changes', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const broadcasts = [];
let state = {
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'oauth',
oauthUrl: 'https://oauth.example/current',
localhostUrl: 'http://localhost:38080/callback',
oauthFlowDeadlineAt: Date.now() + 60000,
oauthFlowDeadlineSourceUrl: 'https://oauth.example/current',
cpaOAuthState: 'cpa-state',
cpaManagementOrigin: 'https://cpa.example.com',
sub2apiSessionId: 'sub-session',
sub2apiOAuthState: 'sub-oauth-state',
sub2apiGroupId: 'group-id',
sub2apiGroupIds: ['group-id'],
sub2apiDraftName: 'draft-name',
sub2apiProxyId: 'proxy-id',
codex2apiSessionId: 'codex-session',
codex2apiOAuthState: 'codex-oauth-state',
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: 'gopay-req',
plusManualConfirmationStep: 9,
plusManualConfirmationMethod: 'gopay',
plusManualConfirmationTitle: 'GoPay 订阅确认',
plusManualConfirmationMessage: '完成后继续 OAuth 登录。',
currentNodeId: 'confirm-oauth',
nodeStatuses: {
'open-chatgpt': 'completed',
'plus-checkout-create': 'completed',
'plus-checkout-billing': 'completed',
'paypal-approve': 'completed',
'plus-checkout-return': 'completed',
'oauth-login': 'completed',
'fetch-login-code': 'completed',
'post-login-phone-verification': 'completed',
'confirm-oauth': 'running',
'platform-verify': 'pending',
},
};
const router = api.createMessageRouter({
addLog: async () => {},
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'plusAccountAccessStrategy')
? { plusAccountAccessStrategy: input.plusAccountAccessStrategy }
: {},
broadcastDataUpdate: (payload) => broadcasts.push(payload),
getNodeIdsForState: (nextState = {}) => (
String(nextState.plusAccountAccessStrategy || '').trim() === 'sub2api_codex_session'
? [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'paypal-approve',
'plus-checkout-return',
'sub2api-session-import',
]
: [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'paypal-approve',
'plus-checkout-return',
'oauth-login',
'fetch-login-code',
'post-login-phone-verification',
'confirm-oauth',
'platform-verify',
]
),
getState: async () => ({ ...state }),
getStepIdsForState: () => [],
setPersistentSettings: async (updates) => ({ ...updates }),
setState: async (updates) => {
state = { ...state, ...updates };
},
});
const response = await router.handleMessage({
type: 'SAVE_SETTING',
payload: {
plusAccountAccessStrategy: 'sub2api_codex_session',
},
});
assert.equal(response.ok, true);
assert.equal(state.plusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(state.currentNodeId, '');
assert.equal(state.oauthUrl, null);
assert.equal(state.localhostUrl, null);
assert.equal(state.oauthFlowDeadlineAt, null);
assert.equal(state.oauthFlowDeadlineSourceUrl, null);
assert.equal(state.cpaOAuthState, null);
assert.equal(state.cpaManagementOrigin, null);
assert.equal(state.sub2apiSessionId, null);
assert.equal(state.sub2apiOAuthState, null);
assert.equal(state.sub2apiGroupId, null);
assert.deepStrictEqual(state.sub2apiGroupIds, []);
assert.equal(state.sub2apiDraftName, null);
assert.equal(state.sub2apiProxyId, null);
assert.equal(state.codex2apiSessionId, null);
assert.equal(state.codex2apiOAuthState, null);
assert.equal(state.plusManualConfirmationPending, false);
assert.equal(state.plusManualConfirmationRequestId, '');
assert.equal(state.plusManualConfirmationStep, 0);
assert.equal(state.plusManualConfirmationMethod, '');
assert.equal(state.plusManualConfirmationTitle, '');
assert.equal(state.plusManualConfirmationMessage, '');
assert.deepStrictEqual(state.nodeStatuses, {
'open-chatgpt': 'pending',
'plus-checkout-create': 'pending',
'plus-checkout-billing': 'pending',
'paypal-approve': 'pending',
'plus-checkout-return': 'pending',
'sub2api-session-import': 'pending',
});
assert.equal(Object.prototype.hasOwnProperty.call(state.nodeStatuses, 'oauth-login'), false);
assert.equal(Object.prototype.hasOwnProperty.call(state.nodeStatuses, 'platform-verify'), false);
assert.deepStrictEqual(broadcasts.at(-1), {
plusAccountAccessStrategy: 'sub2api_codex_session',
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',
'sub2api-session-import': 'pending',
},
currentNodeId: '',
});
});
test('SAVE_SETTING rebuilds Plus node statuses when panel mode forces the effective 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);
const broadcasts = [];
let state = {
panelMode: 'sub2api',
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
oauthUrl: 'https://oauth.example/current',
localhostUrl: 'http://localhost:38080/callback',
sub2apiSessionId: 'sub-session',
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: 'gopay-req',
plusManualConfirmationStep: 9,
plusManualConfirmationMethod: 'gopay',
plusManualConfirmationTitle: 'GoPay 订阅确认',
plusManualConfirmationMessage: '完成后继续导入当前 ChatGPT 会话到 SUB2API。',
currentNodeId: 'sub2api-session-import',
nodeStatuses: {
'open-chatgpt': 'completed',
'plus-checkout-create': 'completed',
'plus-checkout-billing': 'completed',
'paypal-approve': 'completed',
'plus-checkout-return': 'completed',
'sub2api-session-import': 'running',
},
};
const router = api.createMessageRouter({
addLog: async () => {},
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'panelMode')
? { panelMode: input.panelMode }
: {},
broadcastDataUpdate: (payload) => broadcasts.push(payload),
getNodeIdsForState: (nextState = {}) => (
String(nextState.panelMode || '').trim() === 'sub2api'
&& String(nextState.plusAccountAccessStrategy || '').trim() === 'sub2api_codex_session'
? [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'paypal-approve',
'plus-checkout-return',
'sub2api-session-import',
]
: [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'paypal-approve',
'plus-checkout-return',
'oauth-login',
'fetch-login-code',
'post-login-phone-verification',
'confirm-oauth',
'platform-verify',
]
),
getState: async () => ({ ...state }),
getStepIdsForState: () => [],
setPersistentSettings: async (updates) => ({ ...updates }),
setState: async (updates) => {
state = { ...state, ...updates };
},
});
const response = await router.handleMessage({
type: 'SAVE_SETTING',
payload: {
panelMode: 'cpa',
},
});
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.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',
});
assert.equal(Object.prototype.hasOwnProperty.call(state.nodeStatuses, 'sub2api-session-import'), 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: '',
});
});
test('SAVE_SETTING mirrors activeFlowId into flowId when switching to kiro flow', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const broadcasts = [];
let state = { activeFlowId: 'openai', flowId: 'openai', panelMode: 'cpa', plusModeEnabled: false, plusPaymentMethod: 'paypal' };
const router = api.createMessageRouter({
addLog: async () => {},
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'activeFlowId')
? { activeFlowId: input.activeFlowId }
: {},
broadcastDataUpdate: (payload) => broadcasts.push(payload),
getState: async () => ({ ...state }),
setPersistentSettings: async (updates) => ({ ...updates }),
setState: async (updates) => { state = { ...state, ...updates }; },
});
const response = await router.handleMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { activeFlowId: 'kiro' },
});
assert.equal(response.ok, true);
assert.equal(state.activeFlowId, 'kiro');
assert.equal(state.flowId, 'kiro');
assert.deepStrictEqual(broadcasts.at(-1), {
activeFlowId: 'kiro',
flowId: 'kiro',
signupMethod: 'email',
});
});
test('SAVE_SETTING syncs canonical kiro settingsState back into session state', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const canonicalSettingsState = {
schemaVersion: 4,
activeFlowId: 'kiro',
services: {
account: { customPassword: '' },
email: { provider: 'duck' },
proxy: { enabled: false, provider: '711proxy', mode: 'account' },
},
flows: {
openai: {
integrationTargetId: 'cpa',
integrationTargets: {
cpa: { vpsUrl: '', vpsPassword: '', localCpaStep9Mode: 'submit' },
sub2api: {
sub2apiUrl: '',
sub2apiEmail: '',
sub2apiPassword: '',
sub2apiGroupName: 'codex',
sub2apiGroupNames: ['codex', 'openai-plus'],
sub2apiAccountPriority: 1,
sub2apiDefaultProxyName: '',
},
codex2api: { codex2apiUrl: '', codex2apiAdminKey: '' },
},
signup: {
signupMethod: 'email',
phoneVerificationEnabled: false,
phoneSignupReloginAfterBindEmailEnabled: false,
},
plus: {
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
},
autoRun: {
stepExecutionRange: { enabled: false, fromStep: 1, toStep: 11 },
},
},
kiro: {
targetId: 'kiro-rs',
targets: {
'kiro-rs': {
baseUrl: 'https://kiro.example.com/admin',
apiKey: 'live-key',
},
},
autoRun: {
stepExecutionRange: { enabled: false, fromStep: 1, toStep: 9 },
},
},
},
};
let state = {
activeFlowId: 'kiro',
flowId: 'kiro',
kiroTargetId: 'kiro-rs',
kiroRsUrl: 'https://kiro.example.com/admin',
kiroRsKey: '',
settingsSchemaVersion: 4,
settingsState: {
...canonicalSettingsState,
flows: {
...canonicalSettingsState.flows,
kiro: {
...canonicalSettingsState.flows.kiro,
targets: {
'kiro-rs': {
baseUrl: 'https://kiro.example.com/admin',
apiKey: '',
},
},
},
},
},
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
};
const router = api.createMessageRouter({
addLog: async () => {},
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: (input = {}) => ({
activeFlowId: String(input.activeFlowId || 'kiro'),
kiroRsKey: String(input.kiroRsKey || ''),
}),
broadcastDataUpdate: () => {},
getState: async () => ({ ...state }),
setPersistentSettings: async () => ({
activeFlowId: 'kiro',
flowId: 'kiro',
kiroTargetId: 'kiro-rs',
kiroRsUrl: 'https://kiro.example.com/admin',
kiroRsKey: 'live-key',
settingsSchemaVersion: 4,
settingsState: canonicalSettingsState,
}),
setState: async (updates) => {
state = { ...state, ...updates };
},
});
const response = await router.handleMessage({
type: 'SAVE_SETTING',
payload: {
activeFlowId: 'kiro',
kiroRsKey: 'live-key',
},
});
assert.equal(response.ok, true);
assert.equal(state.kiroRsKey, 'live-key');
assert.equal(state.settingsState.flows.kiro.targets['kiro-rs'].apiKey, 'live-key');
});
test('CHECK_KIRO_RS_CONNECTION prefers current sidepanel payload over stale saved kiro.rs config', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const calls = [];
const router = api.createMessageRouter({
getState: async () => ({
activeFlowId: 'kiro',
flowId: 'kiro',
kiroTargetId: 'kiro-rs',
kiroRsUrl: 'https://old.example.com/admin',
kiroRsKey: 'old-key',
settingsState: {
flows: {
kiro: {
targetId: 'kiro-rs',
targets: {
'kiro-rs': {
baseUrl: 'https://old.example.com/admin',
apiKey: 'old-key',
},
},
},
},
},
}),
testKiroRsConnection: async (baseUrl, apiKey) => {
calls.push({ baseUrl, apiKey });
return {
ok: false,
status: 401,
message: 'kiro.rs API Key 被拒绝(HTTP 401:Invalid or missing admin API key)',
};
},
});
const response = await router.handleMessage({
type: 'CHECK_KIRO_RS_CONNECTION',
payload: {
activeFlowId: 'kiro',
targetId: 'kiro-rs',
baseUrl: ' https://new.example.com/admin/ ',
apiKey: ' new-key ',
},
});
assert.equal(response.ok, false);
assert.equal(response.status, 401);
assert.equal(response.message, 'kiro.rs API Key 被拒绝(HTTP 401:Invalid or missing admin API key)');
assert.deepStrictEqual(calls, [
{
baseUrl: 'https://new.example.com/admin/',
apiKey: ' new-key ',
},
]);
});
test('AUTO_RUN applies current flow selection from payload before starting loop', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const calls = [];
const validations = [];
let state = {
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'cpa',
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
};
const router = api.createMessageRouter({
clearStopRequest: () => {},
getPendingAutoRunTimerPlan: () => null,
getState: async () => ({ ...state }),
normalizeRunCount: (value) => Number(value) || 1,
setState: async (updates) => {
calls.push({ type: 'setState', updates: { ...updates } });
state = { ...state, ...updates };
},
startAutoRunLoop: (totalRuns, options) => {
calls.push({ type: 'startAutoRunLoop', totalRuns, options });
},
validateAutoRunStart: (validationState, options = {}) => {
validations.push({
activeFlowId: validationState?.activeFlowId,
flowId: validationState?.flowId,
kiroTargetId: validationState?.kiroTargetId,
optionActiveFlowId: options?.activeFlowId,
});
return { ok: true, errors: [] };
},
});
const response = await router.handleMessage({
type: 'AUTO_RUN',
payload: {
totalRuns: 1,
activeFlowId: 'kiro',
targetId: 'kiro-rs',
},
});
assert.equal(response.ok, true);
assert.equal(state.activeFlowId, 'kiro');
assert.equal(state.flowId, 'kiro');
assert.equal(state.kiroTargetId, 'kiro-rs');
assert.deepStrictEqual(calls, [
{
type: 'setState',
updates: {
activeFlowId: 'kiro',
flowId: 'kiro',
kiroTargetId: 'kiro-rs',
},
},
{
type: 'setState',
updates: {
autoRunSkipFailures: false,
},
},
{
type: 'startAutoRunLoop',
totalRuns: 1,
options: {
autoRunSkipFailures: false,
mode: 'restart',
},
},
]);
assert.deepStrictEqual(validations, [
{
activeFlowId: 'kiro',
flowId: 'kiro',
kiroTargetId: 'kiro-rs',
optionActiveFlowId: 'kiro',
},
]);
});
test('SAVE_SETTING re-resolves signup method when panel mode changes', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
@@ -316,7 +903,7 @@ test('SAVE_SETTING re-resolves signup method when panel mode changes', async ()
broadcastDataUpdate: () => {},
getState: async () => ({ ...state }),
resolveSignupMethod: (nextState = {}) => nextState.panelMode === 'cpa' ? 'email' : 'phone',
setPersistentSettings: async () => {},
setPersistentSettings: async (updates) => ({ ...updates }),
setState: async (updates) => {
state = { ...state, ...updates };
},
@@ -360,6 +947,7 @@ test('SAVE_SETTING applies shared mode-switch normalization before persisting in
),
setPersistentSettings: async (updates) => {
persistedPayloads.push({ ...updates });
return { ...updates };
},
setState: async (updates) => {
state = { ...state, ...updates };
@@ -6,8 +6,25 @@ const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
test('message router appends success record on Plus final step instead of hard-coded step 10', async () => {
function createRouterWithFinalNode(options = {}) {
const finalNodeId = String(options.finalNodeId || 'platform-verify').trim();
const nodeIds = Array.isArray(options.nodeIds) ? options.nodeIds.slice() : [
'open-chatgpt',
'oauth-login',
'fetch-login-code',
'confirm-oauth',
finalNodeId,
];
const nodeStepMap = {
'oauth-login': 10,
'fetch-login-code': 11,
'confirm-oauth': 12,
'platform-verify': 13,
'sub2api-session-import': 10,
...(options.nodeStepMap || {}),
};
const appendCalls = [];
const router = api.createMessageRouter({
addLog: async () => {},
appendAccountRunRecord: async (...args) => {
@@ -43,12 +60,15 @@ test('message router appends success record on Plus final step instead of hard-c
getCurrentLuckmailPurchase: () => null,
getPendingAutoRunTimerPlan: () => null,
getSourceLabel: () => '',
getState: async () => ({ plusModeEnabled: true, nodeStatuses: { 'platform-verify': 'pending' } }),
getNodeIdsForState: () => ['open-chatgpt', 'oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify'],
getStepIdByNodeIdForState: (nodeId) => ({ 'oauth-login': 10, 'fetch-login-code': 11, 'confirm-oauth': 12, 'platform-verify': 13 }[nodeId] || 0),
getLastStepIdForState: () => 13,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'oauth-login' : 'platform-verify' }),
getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
getState: async () => ({ plusModeEnabled: true, nodeStatuses: { [finalNodeId]: 'pending' } }),
getNodeIdsForState: () => nodeIds.slice(),
getStepIdByNodeIdForState: (nodeId) => nodeStepMap[nodeId] || 0,
getLastStepIdForState: () => Math.max(...Object.values(nodeStepMap)),
getStepDefinitionForState: (step) => ({
id: step,
key: Object.entries(nodeStepMap).find(([, mappedStep]) => mappedStep === step)?.[0] || finalNodeId,
}),
getStepIdsForState: () => Object.values(nodeStepMap),
getTabId: async () => null,
getStopRequested: () => false,
handleAutoRunLoopUnhandledError: async () => {},
@@ -80,7 +100,7 @@ test('message router appends success record on Plus final step instead of hard-c
selectLuckmailPurchase: async () => {},
setCurrentHotmailAccount: async () => {},
setCurrentMail2925Account: async () => {},
setContributionMode: async () => {},
setAccountContributionMode: async () => {},
setEmailState: async () => {},
setEmailStateSilently: async () => {},
setIcloudAliasPreservedState: async () => {},
@@ -100,8 +120,86 @@ test('message router appends success record on Plus final step instead of hard-c
verifyHotmailAccount: async () => {},
});
return {
appendCalls,
router,
};
}
test('message router appends success record on Plus final step instead of hard-coded step 10', async () => {
const { appendCalls, router } = createRouterWithFinalNode({
finalNodeId: 'platform-verify',
nodeIds: ['open-chatgpt', 'oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify'],
nodeStepMap: {
'oauth-login': 10,
'fetch-login-code': 11,
'confirm-oauth': 12,
'platform-verify': 13,
},
});
await router.handleMessage({ type: 'NODE_COMPLETE', nodeId: 'platform-verify', payload: { nodeId: 'platform-verify' } }, {});
assert.equal(appendCalls.length, 1);
assert.equal(appendCalls[0][0], 'success');
});
test('message router appends success record when SUB2API session import is the final Plus node', async () => {
const { appendCalls, router } = createRouterWithFinalNode({
finalNodeId: 'sub2api-session-import',
nodeIds: [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'paypal-approve',
'plus-checkout-return',
'sub2api-session-import',
],
nodeStepMap: {
'plus-checkout-create': 6,
'plus-checkout-billing': 7,
'paypal-approve': 8,
'plus-checkout-return': 9,
'sub2api-session-import': 10,
},
});
await router.handleMessage({
type: 'NODE_COMPLETE',
nodeId: 'sub2api-session-import',
payload: { nodeId: 'sub2api-session-import' },
}, {});
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');
});
@@ -46,7 +46,7 @@ test('operationDelayEnabled defaults to enabled and strictly normalizes values',
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', value), true);
}
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', true), true);
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', false), false);
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', false), true);
});
test('operationDelayEnabled is normalized through the background settings payload path', () => {
@@ -63,5 +63,5 @@ test('operationDelayEnabled is normalized through the background settings payloa
for (const value of [undefined, null, '', 0, 'false', true]) {
assert.equal(api.buildPersistentSettingsPayload({ operationDelayEnabled: value }, { fillDefaults: true }).operationDelayEnabled, true);
}
assert.equal(api.buildPersistentSettingsPayload({ operationDelayEnabled: false }).operationDelayEnabled, false);
assert.equal(api.buildPersistentSettingsPayload({ operationDelayEnabled: false }).operationDelayEnabled, true);
});
+37 -2
View File
@@ -124,8 +124,8 @@ test('runtime-state patch accepts nested flow and node updates without legacy st
assert.equal(patch.activeFlowId, 'openai');
assert.equal(patch.activeRunId, 'run-001');
assert.equal(patch.currentNodeId, 'oauth-login');
assert.equal(patch.oauthUrl, 'https://new.example.com/start');
assert.equal(patch.plusCheckoutTabId, 99);
assert.equal(Object.prototype.hasOwnProperty.call(patch, 'oauthUrl'), false);
assert.equal(Object.prototype.hasOwnProperty.call(patch, 'plusCheckoutTabId'), false);
assert.equal(Object.prototype.hasOwnProperty.call(patch, 'currentStep'), false);
assert.equal(Object.prototype.hasOwnProperty.call(patch, 'stepStatuses'), false);
assert.deepStrictEqual(patch.nodeStatuses, {
@@ -135,4 +135,39 @@ test('runtime-state patch accepts nested flow and node updates without legacy st
});
assert.equal(patch.runtimeState.flowState.openai.auth.oauthUrl, 'https://new.example.com/start');
assert.equal(patch.runtimeState.flowState.openai.plus.plusCheckoutTabId, 99);
const view = helpers.buildStateView(patch);
assert.equal(view.oauthUrl, 'https://new.example.com/start');
assert.equal(view.plusCheckoutTabId, 99);
});
test('runtime-state patch prefers explicit activeFlowId over stale legacy flowId', () => {
const api = loadRuntimeStateApi();
const helpers = api.createRuntimeStateHelpers({
DEFAULT_ACTIVE_FLOW_ID: 'openai',
defaultNodeStatuses: {
'open-chatgpt': 'pending',
'kiro-open-register-page': 'pending',
},
});
const patch = helpers.buildSessionStatePatch({
flowId: 'openai',
activeFlowId: 'openai',
nodeStatuses: {
'open-chatgpt': 'completed',
},
}, {
activeFlowId: 'kiro',
nodeStatuses: {
'kiro-open-register-page': 'running',
},
});
assert.equal(patch.activeFlowId, 'kiro');
assert.equal(patch.flowId, 'kiro');
assert.deepStrictEqual(patch.nodeStatuses, {
'open-chatgpt': 'pending',
'kiro-open-register-page': 'running',
});
});
@@ -0,0 +1,515 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const flowRegistrySource = fs.readFileSync('shared/flow-registry.js', 'utf8');
const settingsSchemaSource = fs.readFileSync('shared/settings-schema.js', 'utf8');
const backgroundSource = fs.readFileSync('background.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => backgroundSource.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 i = start; i < backgroundSource.length; i += 1) {
const ch = backgroundSource[i];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < backgroundSource.length; end += 1) {
const ch = backgroundSource[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return backgroundSource.slice(start, end);
}
function buildHarness(extra = '') {
return new Function(`
const self = {};
${flowRegistrySource}
${settingsSchemaSource}
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const DEFAULT_SUB2API_GROUP_NAMES = ['codex', 'openai-plus'];
const SETTINGS_SCHEMA_VIEW_KEYS = Object.freeze([
'activeFlowId',
'openaiIntegrationTargetId',
'panelMode',
'kiroTargetId',
'vpsUrl',
'vpsPassword',
'localCpaStep9Mode',
'sub2apiUrl',
'sub2apiEmail',
'sub2apiPassword',
'sub2apiGroupName',
'sub2apiGroupNames',
'sub2apiAccountPriority',
'sub2apiDefaultProxyName',
'codex2apiUrl',
'codex2apiAdminKey',
'customPassword',
'signupMethod',
'phoneVerificationEnabled',
'phoneSignupReloginAfterBindEmailEnabled',
'plusModeEnabled',
'plusPaymentMethod',
'plusAccountAccessStrategy',
'mailProvider',
'ipProxyEnabled',
'ipProxyService',
'ipProxyMode',
'kiroRsUrl',
'kiroRsKey',
'stepExecutionRangeByFlow',
]);
const SETTINGS_SCHEMA_VIEW_KEY_SET = new Set(SETTINGS_SCHEMA_VIEW_KEYS);
const PERSISTED_SETTING_DEFAULTS = {
activeFlowId: DEFAULT_ACTIVE_FLOW_ID,
panelMode: 'cpa',
signupMethod: 'email',
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'oauth',
phoneVerificationEnabled: false,
mailProvider: '163',
ipProxyEnabled: false,
ipProxyService: '711proxy',
ipProxyMode: 'account',
kiroTargetId: 'kiro-rs',
kiroRsUrl: '',
kiroRsKey: '',
stepExecutionRangeByFlow: {},
};
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);
}
function normalizePanelMode(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'sub2api' || normalized === 'codex2api' ? normalized : 'cpa';
}
function normalizeSignupMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email';
}
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) : [];
}
function normalizeCloudflareDomains(value) { return Array.isArray(value) ? value : []; }
function normalizeCloudflareTempEmailDomains(value) { return Array.isArray(value) ? value : []; }
function normalizeCloudMailDomains(value) { return Array.isArray(value) ? value : []; }
function normalizeMailProvider(value = '') { return String(value || '163').trim().toLowerCase() || '163'; }
function normalizeStepExecutionRangeByFlow(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
function normalizeIpProxyProviderValue(value) { return String(value || '711proxy').trim() || '711proxy'; }
function normalizeIpProxyMode(value) { return String(value || 'account').trim() || 'account'; }
function normalizeIpProxyServiceProfiles(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
function buildIpProxyServiceProfileFromState() {
return {
mode: 'account',
apiUrl: '',
accountList: '',
accountSessionPrefix: '',
accountLifeMinutes: '',
poolTargetCount: '20',
host: '',
port: '',
protocol: 'http',
username: '',
password: '',
region: '',
};
}
function normalizeIpProxyAccountList(value) { return String(value || ''); }
function normalizeIpProxyAccountSessionPrefix(value) { return String(value || ''); }
function normalizeIpProxyAccountLifeMinutes(value) { return String(value || ''); }
function normalizeIpProxyPoolTargetCount(value) { return String(value || '20'); }
function normalizeIpProxyPort(value) { return String(value || '').trim(); }
function normalizeIpProxyProtocol(value) { return String(value || 'http').trim() || 'http'; }
function resolveSignupMethod(state = {}) {
const activeFlowId = String(state?.activeFlowId || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase() || DEFAULT_ACTIVE_FLOW_ID;
if (activeFlowId === 'kiro') {
return 'email';
}
return String(state?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email';
}
function resolveLegacyAutoStepDelaySeconds() { return undefined; }
${extractFunction('normalizePersistentSettingValue')}
${extractFunction('getSettingsSchemaApi')}
${extractFunction('projectSettingsSchemaView')}
${extractFunction('buildPersistedSettingsStoragePayload')}
${extractFunction('buildPersistentSettingsPayload')}
${extractFunction('getPersistedSettings')}
${extractFunction('setPersistentSettings')}
${extra}
return {
buildPersistentSettingsPayload,
getPersistedSettings,
setPersistentSettings,
getRequestedKeys: typeof getRequestedKeys === 'function' ? getRequestedKeys : () => [],
getPersistedWrites: typeof getPersistedWrites === 'function' ? getPersistedWrites : () => [],
getRemovedKeys: typeof getRemovedKeys === 'function' ? getRemovedKeys : () => [],
};
`)();
}
test('buildPersistentSettingsPayload writes canonical settings schema into persisted payloads when defaults are materialized', () => {
const api = buildHarness();
const payload = api.buildPersistentSettingsPayload({
activeFlowId: 'kiro',
kiroRsUrl: 'https://kiro.example.com/admin',
kiroRsKey: 'secret-key',
}, { fillDefaults: true });
assert.equal(payload.activeFlowId, 'kiro');
assert.equal(payload.kiroTargetId, 'kiro-rs');
assert.equal(payload.kiroRsUrl, 'https://kiro.example.com/admin');
assert.equal(payload.kiroRsKey, 'secret-key');
assert.equal(Object.prototype.hasOwnProperty.call(payload, 'kiroRegion'), false);
assert.equal(payload.settingsSchemaVersion, 4);
assert.equal(payload.settingsState.activeFlowId, 'kiro');
assert.equal(payload.settingsState.flows.kiro.targetId, 'kiro-rs');
assert.equal(
payload.settingsState.flows.kiro.targets['kiro-rs'].baseUrl,
'https://kiro.example.com/admin'
);
});
test('buildPersistentSettingsPayload accepts schema-only input when requireKnownKeys is enabled', () => {
const api = buildHarness();
const payload = api.buildPersistentSettingsPayload({
settingsSchemaVersion: 4,
settingsState: {
activeFlowId: 'kiro',
services: {
account: { customPassword: '' },
email: { provider: '163' },
proxy: { enabled: false, provider: '711proxy', mode: 'account' },
},
flows: {
openai: {
integrationTargetId: 'cpa',
integrationTargets: {
cpa: {
vpsUrl: '',
vpsPassword: '',
localCpaStep9Mode: 'submit',
},
sub2api: {
sub2apiUrl: '',
sub2apiEmail: '',
sub2apiPassword: '',
sub2apiGroupName: 'codex',
sub2apiGroupNames: ['codex', 'openai-plus'],
sub2apiAccountPriority: 1,
sub2apiDefaultProxyName: '',
},
codex2api: {
codex2apiUrl: '',
codex2apiAdminKey: '',
},
},
signup: {
signupMethod: 'email',
phoneVerificationEnabled: false,
phoneSignupReloginAfterBindEmailEnabled: false,
},
plus: {
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'oauth',
},
autoRun: {
stepExecutionRange: { enabled: false, fromStep: 1, toStep: 11 },
},
},
kiro: {
targetId: 'kiro-rs',
targets: {
'kiro-rs': {
baseUrl: 'https://kiro.example.com/admin',
apiKey: 'schema-only-key',
},
},
autoRun: {
stepExecutionRange: { enabled: true, fromStep: 1, toStep: 9 },
},
},
},
},
}, { requireKnownKeys: true });
assert.equal(payload.activeFlowId, 'kiro');
assert.equal(payload.kiroTargetId, 'kiro-rs');
assert.equal(payload.kiroRsUrl, 'https://kiro.example.com/admin');
assert.equal(payload.kiroRsKey, 'schema-only-key');
assert.equal(Object.prototype.hasOwnProperty.call(payload, 'kiroRegion'), false);
assert.equal(payload.settingsSchemaVersion, 4);
assert.equal(payload.settingsState.flows.openai.plus.plusAccountAccessStrategy, 'oauth');
});
test('getPersistedSettings reads schema keys alongside legacy flat settings keys', async () => {
const api = buildHarness(`
let requestedKeys = [];
const chrome = {
storage: {
local: {
async get(keys) {
requestedKeys = Array.isArray(keys) ? [...keys] : [];
return {};
},
},
},
};
function getRequestedKeys() {
return requestedKeys;
}
`);
const state = await api.getPersistedSettings();
assert.ok(api.getRequestedKeys().includes('settingsSchemaVersion'));
assert.ok(api.getRequestedKeys().includes('settingsState'));
assert.ok(api.getRequestedKeys().includes('plusAccountAccessStrategy'));
assert.equal(state.settingsSchemaVersion, 4);
assert.equal(state.settingsState.activeFlowId, 'openai');
});
test('getPersistedSettings can project schema-only storage back into legacy flat settings', async () => {
const api = buildHarness(`
const chrome = {
storage: {
local: {
async get() {
return {
settingsSchemaVersion: 4,
settingsState: {
activeFlowId: 'kiro',
services: {
account: { customPassword: '' },
email: { provider: 'hotmail' },
proxy: { enabled: true, provider: '711proxy', mode: 'account' },
},
flows: {
openai: {
integrationTargetId: 'sub2api',
integrationTargets: {
cpa: {
vpsUrl: '',
vpsPassword: '',
localCpaStep9Mode: 'submit',
},
sub2api: {
sub2apiUrl: '',
sub2apiEmail: '',
sub2apiPassword: '',
sub2apiGroupName: 'codex',
sub2apiGroupNames: ['codex', 'openai-plus'],
sub2apiAccountPriority: 1,
sub2apiDefaultProxyName: '',
},
codex2api: {
codex2apiUrl: '',
codex2apiAdminKey: '',
},
},
signup: {
signupMethod: 'email',
phoneVerificationEnabled: false,
phoneSignupReloginAfterBindEmailEnabled: false,
},
plus: {
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
},
autoRun: {
stepExecutionRange: { enabled: false, fromStep: 1, toStep: 11 },
},
},
kiro: {
targetId: 'kiro-rs',
targets: {
'kiro-rs': {
baseUrl: 'https://kiro.example.com/admin',
apiKey: 'stored-key',
},
},
autoRun: {
stepExecutionRange: { enabled: true, fromStep: 1, toStep: 9 },
},
},
},
},
};
},
},
},
};
`);
const state = await api.getPersistedSettings();
assert.equal(state.activeFlowId, 'kiro');
assert.equal(state.panelMode, 'sub2api');
assert.equal(state.mailProvider, 'hotmail');
assert.equal(state.ipProxyEnabled, true);
assert.equal(state.kiroRsUrl, 'https://kiro.example.com/admin');
assert.equal(state.kiroRsKey, 'stored-key');
assert.equal(state.plusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(Object.prototype.hasOwnProperty.call(state, 'kiroRegion'), false);
assert.deepEqual(state.stepExecutionRangeByFlow.kiro, {
enabled: true,
fromStep: 1,
toStep: 9,
});
});
test('setPersistentSettings materializes canonical schema keys for schema-only updates', async () => {
const api = buildHarness(`
const persistedWrites = [];
const removedKeys = [];
const chrome = {
storage: {
local: {
async get() {
return {};
},
async remove(keys) {
removedKeys.push(...(Array.isArray(keys) ? keys : [keys]));
},
async set(payload) {
persistedWrites.push(JSON.parse(JSON.stringify(payload)));
},
},
},
};
function getPersistedWrites() {
return persistedWrites;
}
function getRemovedKeys() {
return removedKeys;
}
`);
const persisted = await api.setPersistentSettings({
settingsSchemaVersion: 4,
settingsState: {
activeFlowId: 'kiro',
services: {
account: { customPassword: '' },
email: { provider: '163' },
proxy: { enabled: false, provider: '711proxy', mode: 'account' },
},
flows: {
openai: {
integrationTargetId: 'cpa',
integrationTargets: {
cpa: {
vpsUrl: '',
vpsPassword: '',
localCpaStep9Mode: 'submit',
},
sub2api: {
sub2apiUrl: '',
sub2apiEmail: '',
sub2apiPassword: '',
sub2apiGroupName: 'codex',
sub2apiGroupNames: ['codex', 'openai-plus'],
sub2apiAccountPriority: 1,
sub2apiDefaultProxyName: '',
},
codex2api: {
codex2apiUrl: '',
codex2apiAdminKey: '',
},
},
signup: {
signupMethod: 'email',
phoneVerificationEnabled: false,
phoneSignupReloginAfterBindEmailEnabled: false,
},
plus: {
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
},
autoRun: {
stepExecutionRange: { enabled: false, fromStep: 1, toStep: 11 },
},
},
kiro: {
targetId: 'kiro-rs',
targets: {
'kiro-rs': {
baseUrl: 'https://kiro.example.com/admin',
apiKey: 'nested-only-key',
},
},
autoRun: {
stepExecutionRange: { enabled: true, fromStep: 1, toStep: 9 },
},
},
},
},
});
const write = api.getPersistedWrites().at(-1);
assert.equal(persisted.activeFlowId, 'kiro');
assert.equal(persisted.kiroTargetId, 'kiro-rs');
assert.equal(persisted.kiroRsUrl, 'https://kiro.example.com/admin');
assert.equal(persisted.kiroRsKey, 'nested-only-key');
assert.equal(persisted.plusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(Object.prototype.hasOwnProperty.call(persisted, 'kiroRegion'), false);
assert.equal(persisted.settingsSchemaVersion, 4);
assert.equal(Object.prototype.hasOwnProperty.call(write, 'activeFlowId'), false);
assert.equal(Object.prototype.hasOwnProperty.call(write, 'kiroRsUrl'), false);
assert.equal(Object.prototype.hasOwnProperty.call(write, 'kiroRsKey'), false);
assert.equal(Object.prototype.hasOwnProperty.call(write, 'kiroRegion'), false);
assert.equal(write.settingsSchemaVersion, 4);
assert.equal(write.settingsState.activeFlowId, 'kiro');
assert.equal(write.settingsState.flows.openai.plus.plusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(write.settingsState.flows.kiro.targetId, 'kiro-rs');
assert.ok(api.getRemovedKeys().includes('panelMode'));
assert.ok(api.getRemovedKeys().includes('kiroRsUrl'));
});
@@ -58,7 +58,7 @@ let state = {
signupMethod: 'phone',
phoneVerificationEnabled: true,
plusModeEnabled: false,
contributionMode: false,
accountContributionEnabled: false,
resolvedSignupMethod: null,
};
async function getState() { return { ...state }; }
@@ -146,6 +146,10 @@ const captured = [];
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_ACTIVE_FLOW_ID = 'openai';
const self = {
MultiPageStepDefinitions: {
getSteps(options) {
@@ -160,8 +164,10 @@ const self = {
};
${extractFunction('isPlusModeState')}
${extractFunction('normalizePlusPaymentMethod')}
${extractFunction('normalizePlusAccountAccessStrategy')}
${extractFunction('normalizeSignupMethod')}
${extractFunction('getSignupMethodForStepDefinitions')}
${extractFunction('buildResolvedStepDefinitionState')}
${extractFunction('getStepDefinitionsForState')}
return {
getCaptured: () => captured.slice(),
@@ -180,6 +186,7 @@ return {
activeFlowId: 'openai',
plusModeEnabled: true,
plusPaymentMethod: 'gopay',
plusAccountAccessStrategy: 'oauth',
signupMethod: 'phone',
phoneSignupReloginAfterBindEmailEnabled: false,
}]);
+65 -4
View File
@@ -17,6 +17,18 @@ const OPENAI_NODE_IDS = [
'platform-verify',
];
const KIRO_NODE_IDS = [
'kiro-open-register-page',
'kiro-submit-email',
'kiro-submit-name',
'kiro-submit-verification-code',
'kiro-submit-password',
'kiro-complete-register-consent',
'kiro-start-desktop-authorize',
'kiro-complete-desktop-authorize',
'kiro-upload-credential',
];
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
@@ -61,7 +73,7 @@ function extractFunction(name) {
return source.slice(start, end);
}
function createApi() {
function createApi(nodeIds = OPENAI_NODE_IDS) {
const bundle = [
extractFunction('isStepDoneStatus'),
extractFunction('skipNode'),
@@ -70,11 +82,18 @@ function createApi() {
return new Function(`
const DEFAULT_STATE = { nodeStatuses: {} };
function getNodeIdsForState() {
return ${JSON.stringify(OPENAI_NODE_IDS)};
return ${JSON.stringify(nodeIds)};
}
function normalizeStatusMapForNodes(statuses = {}) {
return { ...statuses };
}
function assertNodeExecutionAllowedForState() {}
function isNodeExecutionAllowedForState() {
return true;
}
function getExecutionAllowedNodeIdsForState() {
return ${JSON.stringify(nodeIds)};
}
${bundle}
return { skipNode };
`)();
@@ -111,11 +130,12 @@ test('skipNode cascades from open-chatgpt through signup profile when downstream
{ nodeId: 'fill-password', status: 'skipped' },
{ nodeId: 'fetch-signup-code', status: 'skipped' },
{ nodeId: 'fill-profile', status: 'skipped' },
{ nodeId: 'wait-registration-success', status: 'skipped' },
]);
assert.equal(events.logs[0]?.message, '节点 open-chatgpt 已跳过');
assert.equal(
events.logs[1]?.message,
'节点 open-chatgpt 已跳过,节点 submit-signup-email、fill-password、fetch-signup-code、fill-profile 也已同时跳过。'
'节点 open-chatgpt 已跳过,节点 submit-signup-email、fill-password、fetch-signup-code、fill-profile、wait-registration-success 也已同时跳过。'
);
});
@@ -157,11 +177,52 @@ test('skipNode from open-chatgpt skips only unfinished linked signup nodes', asy
assert.deepStrictEqual(events.setNodeStatusCalls, [
{ nodeId: 'open-chatgpt', status: 'skipped' },
{ nodeId: 'fetch-signup-code', status: 'skipped' },
{ nodeId: 'wait-registration-success', status: 'skipped' },
]);
assert.equal(
events.logs.some(({ message }) => (
message === '节点 open-chatgpt 已跳过,节点 fetch-signup-code 也已同时跳过。'
message === '节点 open-chatgpt 已跳过,节点 fetch-signup-code、wait-registration-success 也已同时跳过。'
)),
true
);
});
test('skipNode cascades from kiro open-register through the whole register branch', async () => {
const statuses = Object.fromEntries(KIRO_NODE_IDS.map((nodeId) => [nodeId, 'pending']));
const events = {
setNodeStatusCalls: [],
logs: [],
};
const api = createApi(KIRO_NODE_IDS);
globalThis.ensureManualInteractionAllowed = async () => ({
nodeStatuses: { ...statuses },
});
globalThis.getState = async () => ({
nodeStatuses: { ...statuses },
});
globalThis.setNodeStatus = async (nodeId, status) => {
events.setNodeStatusCalls.push({ nodeId, status });
statuses[nodeId] = status;
};
globalThis.addLog = async (message, level) => {
events.logs.push({ message, level });
};
const result = await api.skipNode('kiro-open-register-page');
assert.deepStrictEqual(result, { ok: true, nodeId: 'kiro-open-register-page', status: 'skipped' });
assert.deepStrictEqual(events.setNodeStatusCalls, [
{ nodeId: 'kiro-open-register-page', status: 'skipped' },
{ nodeId: 'kiro-submit-email', status: 'skipped' },
{ nodeId: 'kiro-submit-name', status: 'skipped' },
{ nodeId: 'kiro-submit-verification-code', status: 'skipped' },
{ nodeId: 'kiro-submit-password', status: 'skipped' },
{ nodeId: 'kiro-complete-register-consent', status: 'skipped' },
]);
assert.equal(events.logs[0]?.message, '节点 kiro-open-register-page 已跳过');
assert.equal(
events.logs[1]?.message,
'节点 kiro-open-register-page 已跳过,节点 kiro-submit-email、kiro-submit-name、kiro-submit-verification-code、kiro-submit-password、kiro-complete-register-consent 也已同时跳过。'
);
});
+1 -1
View File
@@ -61,7 +61,7 @@ function getErrorMessage(error) {
}
async function getState() {
events.push({ type: 'getState' });
return { nodeStatuses: {}, contributionMode: true };
return { nodeStatuses: {}, accountContributionEnabled: true };
}
function getLastNodeIdForState() {
return lastNodeId;
@@ -0,0 +1,164 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
const NODE_IDS = [
'open-chatgpt',
'submit-signup-email',
'fill-password',
'fetch-signup-code',
'fill-profile',
'wait-registration-success',
'oauth-login',
'fetch-login-code',
'confirm-oauth',
'platform-verify',
];
const NODE_STEPS = Object.fromEntries(NODE_IDS.map((nodeId, index) => [nodeId, index + 1]));
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 i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = i;
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);
}
function createApi() {
const bundle = [
'isPlainObjectValue',
'normalizeStepExecutionRangeFlowId',
'hasStepExecutionRangeShape',
'normalizePositiveStepNumber',
'normalizeStepExecutionRangeEntry',
'normalizeStepExecutionRangeByFlow',
'getStepExecutionRangeForState',
'isStepAllowedByExecutionRangeForState',
'isNodeExecutionAllowedForState',
'getExecutionAllowedNodeIdsForState',
'assertNodeExecutionAllowedForState',
'isStepDoneStatus',
'normalizeStatusMapForNodes',
'getFirstUnfinishedNodeId',
'hasSavedNodeProgress',
].map(extractFunction).join('\n');
return new Function(`
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const DEFAULT_STATE = { nodeStatuses: ${JSON.stringify(Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, 'pending'])))} };
function getNodeIdsForState() {
return ${JSON.stringify(NODE_IDS)};
}
function getStepIdByNodeIdForState(nodeId) {
return ${JSON.stringify(NODE_STEPS)}[String(nodeId || '').trim()] || 0;
}
function getNodeIdByStepForState(step) {
return ${JSON.stringify(Object.fromEntries(Object.entries(NODE_STEPS).map(([nodeId, step]) => [step, nodeId])))}[Number(step)] || '';
}
${bundle}
return {
normalizeStepExecutionRangeByFlow,
getStepExecutionRangeForState,
isNodeExecutionAllowedForState,
getExecutionAllowedNodeIdsForState,
assertNodeExecutionAllowedForState,
getFirstUnfinishedNodeId,
hasSavedNodeProgress,
};
`)();
}
test('step execution range normalizes codex flow config to the active OpenAI flow', () => {
const api = createApi();
assert.deepStrictEqual(
api.normalizeStepExecutionRangeByFlow({
codex: { enabled: true, fromStep: 6, toStep: 3 },
}),
{
openai: { enabled: true, fromStep: 3, toStep: 6 },
}
);
});
test('step execution range disables nodes outside the configured bounds', () => {
const api = createApi();
const state = {
activeFlowId: 'openai',
stepExecutionRangeByFlow: {
openai: { enabled: true, fromStep: 3, toStep: 6 },
},
nodeStatuses: Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, 'pending'])),
};
assert.equal(api.isNodeExecutionAllowedForState('open-chatgpt', state), false);
assert.equal(api.isNodeExecutionAllowedForState('fill-password', state), true);
assert.deepStrictEqual(api.getExecutionAllowedNodeIdsForState(state), [
'fill-password',
'fetch-signup-code',
'fill-profile',
'wait-registration-success',
]);
assert.equal(api.getFirstUnfinishedNodeId(state.nodeStatuses, state), 'fill-password');
assert.throws(
() => api.assertNodeExecutionAllowedForState('open-chatgpt', state, '手动执行节点'),
/执行范围禁用/
);
});
test('step execution range ignores progress outside the allowed range', () => {
const api = createApi();
const state = {
activeFlowId: 'openai',
stepExecutionRangeByFlow: {
openai: { enabled: true, fromStep: 3, toStep: 6 },
},
nodeStatuses: {
'open-chatgpt': 'completed',
'submit-signup-email': 'completed',
'fill-password': 'pending',
},
};
assert.equal(api.hasSavedNodeProgress(state.nodeStatuses, state), false);
state.nodeStatuses['fill-password'] = 'completed';
assert.equal(api.hasSavedNodeProgress(state.nodeStatuses, state), true);
assert.equal(api.getFirstUnfinishedNodeId(state.nodeStatuses, state), 'fetch-signup-code');
});
+9 -1
View File
@@ -2,7 +2,7 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports step 1~10 modules', () => {
test('background imports workflow step modules including rebuilt Kiro modules', () => {
const source = fs.readFileSync('background.js', 'utf8');
[
@@ -16,7 +16,15 @@ test('background imports step 1~10 modules', () => {
'background/steps/fetch-login-code.js',
'background/steps/confirm-oauth.js',
'background/steps/platform-verify.js',
'shared/kiro-timeouts.js',
'background/kiro/state.js',
'background/kiro/register-runner.js',
'background/kiro/desktop-client.js',
'background/kiro/desktop-authorize-runner.js',
'background/kiro/publisher-kiro-rs.js',
].forEach((path) => {
assert.match(source, new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});
assert.doesNotMatch(source, /background\/steps\/kiro-device-auth\.js/);
});
@@ -0,0 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background node registry preserves node metadata even before an executor is registered', () => {
const source = fs.readFileSync('background/steps/registry.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageBackgroundStepRegistry;`)({});
const registry = api.createNodeRegistry([
{
flowId: 'kiro',
nodeId: 'kiro-open-register-page',
displayOrder: 1,
executeKey: 'kiro-open-register-page',
title: '打开注册页',
},
]);
const node = registry.getNodeDefinition('kiro-open-register-page');
assert.equal(node.flowId, 'kiro');
assert.equal(node.displayOrder, 1);
assert.equal(node.title, '打开注册页');
assert.throws(
() => registry.executeNode('kiro-open-register-page', {}),
/Missing node executor: kiro-open-register-page/
);
});
test('background node registry executes registered nodes in display order', async () => {
const source = fs.readFileSync('background/steps/registry.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageBackgroundStepRegistry;`)({});
const events = [];
const registry = api.createNodeRegistry([
{
flowId: 'openai',
displayOrder: 2,
nodeId: 'submit-signup-email',
executeKey: 'submit-signup-email',
title: 'Submit signup email',
execute: async (state) => {
events.push({ type: 'execute', state });
},
},
{
flowId: 'openai',
displayOrder: 1,
nodeId: 'open-chatgpt',
executeKey: 'open-chatgpt',
title: 'Open ChatGPT',
},
]);
assert.deepStrictEqual(
registry.getOrderedNodes().map((node) => node.nodeId),
['open-chatgpt', 'submit-signup-email']
);
await registry.executeNode('submit-signup-email', { activeFlowId: 'openai' });
assert.deepStrictEqual(events, [{ type: 'execute', state: { activeFlowId: 'openai' } }]);
});
+29 -44
View File
@@ -2,57 +2,42 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports node registry and shared workflow definitions', () => {
test('background imports node registry and wires the rebuilt Kiro executors', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/steps\/registry\.js/);
assert.match(source, /data\/step-definitions\.js/);
assert.match(source, /background\/workflow-engine\.js/);
assert.match(source, /MultiPageStepDefinitions\?\.getNodes/);
assert.match(source, /getStepRegistryForState\(state\)/);
assert.match(source, /buildNodeRegistry\(definitions/);
assert.match(source, /PLUS_PAYPAL_STEP_DEFINITIONS/);
assert.match(source, /PLUS_GOPAY_STEP_DEFINITIONS/);
assert.match(source, /NORMAL_PHONE_STEP_DEFINITIONS/);
assert.match(source, /NORMAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS/);
assert.match(source, /PLUS_PAYPAL_PHONE_STEP_DEFINITIONS/);
assert.match(source, /PLUS_PAYPAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS/);
assert.match(source, /PLUS_GOPAY_PHONE_STEP_DEFINITIONS/);
assert.match(source, /PLUS_GOPAY_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS/);
assert.match(source, /PLUS_GPC_PHONE_STEP_DEFINITIONS/);
assert.match(source, /PLUS_GPC_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS/);
assert.match(source, /plusPayPalStepRegistry/);
assert.match(source, /plusGoPayStepRegistry/);
assert.match(source, /normalPhoneStepRegistry/);
assert.match(source, /normalPhoneBoundEmailReloginStepRegistry/);
assert.match(source, /plusPayPalPhoneStepRegistry/);
assert.match(source, /plusPayPalPhoneBoundEmailReloginStepRegistry/);
assert.match(source, /plusGoPayPhoneStepRegistry/);
assert.match(source, /plusGoPayPhoneBoundEmailReloginStepRegistry/);
assert.match(source, /plusGpcPhoneStepRegistry/);
assert.match(source, /plusGpcPhoneBoundEmailReloginStepRegistry/);
assert.match(source, /const signupMethod = getSignupMethodForStepDefinitions\(state\);/);
assert.match(source, /const useBoundEmailRelogin = signupMethod === SIGNUP_METHOD_PHONE/);
assert.match(source, /useBoundEmailRelogin \? normalPhoneBoundEmailReloginStepRegistry : normalPhoneStepRegistry/);
assert.match(source, /const paymentMethod = normalizePlusPaymentMethod\(state\?\.plusPaymentMethod\);/);
assert.match(source, /paymentMethod === PLUS_PAYMENT_METHOD_GOPAY/);
assert.match(source, /useBoundEmailRelogin \? plusGoPayPhoneBoundEmailReloginStepRegistry : plusGoPayPhoneStepRegistry/);
assert.match(source, /useBoundEmailRelogin \? plusPayPalPhoneBoundEmailReloginStepRegistry : plusPayPalPhoneStepRegistry/);
assert.match(source, /useBoundEmailRelogin \? plusGpcPhoneBoundEmailReloginStepRegistry : plusGpcPhoneStepRegistry/);
assert.match(source, /activeStepRegistry\.executeNode\(normalizedNodeId,\s*\{/);
assert.match(source, /'bind-email': \(state\) => step8Executor\.executeBindEmail\(state\)/);
assert.match(source, /'fetch-bind-email-code': \(state\) => step8Executor\.executeFetchBindEmailCode\(state\)/);
assert.match(source, /'relogin-bound-email': \(state\) => executeReloginBoundEmail\(state\)/);
assert.match(source, /'fetch-bound-email-login-code': \(state\) => step8Executor\.executeBoundEmailLoginCode\(state\)/);
assert.match(source, /'post-bound-email-phone-verification': \(state\) => step8Executor\.executeBoundEmailPostLoginPhoneVerification\(state\)/);
assert.match(source, /background\/steps\/create-plus-checkout\.js/);
assert.match(source, /background\/steps\/fill-plus-checkout\.js/);
assert.match(source, /background\/steps\/gopay-manual-confirm\.js/);
assert.match(source, /'gopay-subscription-confirm': \(state\) => goPayManualConfirmExecutor\.executeGoPayManualConfirm\(state\)/);
assert.match(source, /background\/steps\/paypal-approve\.js/);
assert.match(source, /background\/steps\/gopay-approve\.js/);
assert.match(source, /background\/steps\/plus-return-confirm\.js/);
});
assert.match(source, /const stepRegistryCache = new Map\(\);/);
assert.match(source, /const definitions = getNodeDefinitionsForState\(state\);/);
assert.match(source, /stepRegistryCache\.set\(cacheKey, buildStepRegistry\(definitions\)\)/);
assert.match(source, /background\/kiro\/register-runner\.js/);
assert.match(source, /background\/kiro\/desktop-client\.js/);
assert.match(source, /background\/kiro\/desktop-authorize-runner\.js/);
assert.match(source, /background\/kiro\/publisher-kiro-rs\.js/);
assert.doesNotMatch(source, /background\/steps\/kiro-device-auth\.js/);
assert.match(source, /const kiroRegisterRunner = self\.MultiPageBackgroundKiroRegisterRunner\?\.createKiroRegisterRunner\(/);
assert.match(source, /const kiroDesktopAuthorizeRunner = self\.MultiPageBackgroundKiroDesktopAuthorizeRunner\?\.createKiroDesktopAuthorizeRunner\(/);
assert.match(source, /const kiroPublisher = self\.MultiPageBackgroundKiroPublisherKiroRs\?\.createKiroRsPublisher\(/);
assert.match(source, /'kiro-open-register-page': \(state\) => kiroRegisterRunner\.executeKiroOpenRegisterPage\(state\)/);
assert.match(source, /'kiro-submit-email': \(state\) => kiroRegisterRunner\.executeKiroSubmitEmail\(state\)/);
assert.match(source, /'kiro-submit-name': \(state\) => kiroRegisterRunner\.executeKiroSubmitName\(state\)/);
assert.match(source, /'kiro-submit-verification-code': \(state\) => kiroRegisterRunner\.executeKiroSubmitVerificationCode\(state\)/);
assert.match(source, /'kiro-submit-password': \(state\) => kiroRegisterRunner\.executeKiroSubmitPassword\(state\)/);
assert.match(source, /'kiro-complete-register-consent': \(state\) => kiroRegisterRunner\.executeKiroCompleteRegisterConsent\(state\)/);
assert.match(source, /'kiro-start-desktop-authorize': \(state\) => kiroDesktopAuthorizeRunner\.executeKiroStartDesktopAuthorize\(state\)/);
assert.match(source, /'kiro-complete-desktop-authorize': \(state\) => kiroDesktopAuthorizeRunner\.executeKiroCompleteDesktopAuthorize\(state\)/);
assert.match(source, /'kiro-upload-credential': \(state\) => kiroPublisher\.executeKiroUploadCredential\(state\)/);
assert.match(
source,
/'kiro-open-register-page',[\s\S]*'kiro-submit-email',[\s\S]*'kiro-submit-name',[\s\S]*'kiro-submit-verification-code',[\s\S]*'kiro-submit-password',[\s\S]*'kiro-complete-register-consent',[\s\S]*'kiro-start-desktop-authorize',[\s\S]*'kiro-complete-desktop-authorize',[\s\S]*'kiro-upload-credential'/
);
});
test('GoPay approve executor receives debugger click and manual OTP helpers', () => {
const source = fs.readFileSync('background.js', 'utf8');
@@ -28,6 +28,17 @@ test('step 1 cookie cleanup queries target domains and skips browsingData sweep
{ domain: '.chatgpt.com', path: '/', name: 'session', storeId: 'store-a' },
];
}
if (query?.domain === 'openai.com') {
return [
{
domain: '.openai.com',
path: '/',
name: 'shared',
storeId: 'store-a',
partitionKey: { topLevelSite: 'https://chatgpt.com' },
},
];
}
return [];
},
remove: async (details) => {
@@ -63,6 +74,12 @@ test('step 1 cookie cleanup queries target domains and skips browsingData sweep
name: 'session',
storeId: 'store-a',
},
{
url: 'https://openai.com/',
name: 'shared',
storeId: 'store-a',
partitionKey: { topLevelSite: 'https://chatgpt.com' },
},
]);
assert.deepStrictEqual(events.browsingDataCalls, []);
assert.deepStrictEqual(events.openedSteps, [1]);
@@ -588,6 +588,72 @@ test('step 7 keeps Plus email login even when phone sms runtime exists', async (
assert.equal(events.payloads[0].accountIdentifier, 'plus.user@example.com');
});
test('step 7 keeps relogin-bound-email as the active node id', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
const events = {
completions: [],
messages: [],
};
const executor = api.createStep7Executor({
addLog: async () => {},
completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({
email: 'bound.user@example.com',
password: 'secret',
plusModeEnabled: true,
signupMethod: 'phone',
phoneSignupReloginAfterBindEmailEnabled: true,
}),
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/relogin',
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async (_sourceName, message) => {
events.messages.push(message);
return {
step6Outcome: 'success',
state: 'verification_page',
loginVerificationRequestedAt: 223344,
};
},
STEP6_MAX_ATTEMPTS: 3,
throwIfStopped: () => {},
});
await executor.executeStep7({
nodeId: 'relogin-bound-email',
visibleStep: 14,
forceLoginIdentifierType: 'email',
forceEmailLogin: true,
signupMethod: 'email',
resolvedSignupMethod: 'email',
accountIdentifierType: 'email',
accountIdentifier: 'bound.user@example.com',
email: 'bound.user@example.com',
password: 'secret',
});
assert.equal(events.messages.length, 1);
assert.equal(events.messages[0].nodeId, 'relogin-bound-email');
assert.equal(events.messages[0].payload.visibleStep, 14);
assert.deepStrictEqual(events.completions, [
{
step: 'relogin-bound-email',
payload: {
loginVerificationRequestedAt: 223344,
},
},
]);
});
test('step 7 keeps phone login after step 8 stores an unbound email for phone signup', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
@@ -0,0 +1,605 @@
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('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_token,accessToken 过期后无法自动续期',
}],
},
});
}
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',
'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'/);
});
+109
View File
@@ -152,6 +152,60 @@ test('tab runtime gives step 5 profile submit enough response time for slow page
);
});
test('tab runtime replays retryable transport recovery hook and surfaces a localized timeout error', async () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
let recoveryCalls = 0;
const runtime = api.createTabRuntime({
LOG_PREFIX: '[test]',
addLog: async () => {},
chrome: {
tabs: {
get: async () => ({
id: 9,
windowId: 1,
url: 'https://profile.aws.amazon.com/complete',
status: 'complete',
}),
query: async () => [],
sendMessage: async () => {
throw new Error('Could not establish connection. Receiving end does not exist.');
},
},
},
getSourceLabel: () => 'Kiro 授权页',
getState: async () => ({
tabRegistry: {
'kiro-register-page': { tabId: 9, ready: true },
},
sourceLastUrls: {},
}),
isRetryableContentScriptTransportError: (error) => /Receiving end does not exist/i.test(String(error?.message || error || '')),
matchesSourceUrlFamily: () => false,
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
runtime.sendToContentScriptResilient('kiro-register-page', {
type: 'ENSURE_KIRO_PAGE_STATE',
payload: {},
}, {
timeoutMs: 5,
retryDelayMs: 0,
onRetryableError: async () => {
recoveryCalls += 1;
},
}),
/页面刚完成跳转或刷新,内容脚本还没有重新接回/
);
assert.equal(recoveryCalls > 0, true);
});
test('tab runtime waitForTabComplete waits until tab status becomes complete', async () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
@@ -329,6 +383,61 @@ test('tab runtime opens new automation tabs in the locked window', async () => {
assert.equal(created[0].windowId, 100);
});
test('tab runtime force-new opens replacement before removing the active stale source tab', async () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
const events = [];
let tabs = [
{ id: 1, active: true, windowId: 100, url: 'https://chatgpt.com/' },
];
const runtime = api.createTabRuntime({
LOG_PREFIX: '[test]',
addLog: async () => {},
chrome: {
tabs: {
create: async (payload) => {
events.push({ type: 'create', payload });
const tab = { id: 2, active: true, windowId: payload.windowId, url: payload.url };
tabs = tabs.map((item) => ({ ...item, active: false })).concat(tab);
return tab;
},
get: async (tabId) => tabs.find((tab) => tab.id === tabId),
query: async () => tabs,
remove: async (ids) => {
events.push({ type: 'remove', ids });
tabs = tabs.filter((tab) => !ids.includes(tab.id));
},
},
},
getSourceLabel: (sourceName) => sourceName || 'unknown',
getState: async () => ({
automationWindowId: 100,
sourceLastUrls: { 'signup-page': 'https://chatgpt.com/' },
tabRegistry: {},
}),
matchesSourceUrlFamily: (sourceName, candidateUrl) => (
sourceName === 'signup-page'
&& /chatgpt\.com|auth\.openai\.com/.test(String(candidateUrl || ''))
),
setState: async () => {},
throwIfStopped: () => {},
});
const tabId = await runtime.reuseOrCreateTab('signup-page', 'https://auth.openai.com/authorize', {
forceNew: true,
});
assert.equal(tabId, 2);
assert.deepEqual(events, [
{ type: 'create', payload: { url: 'https://auth.openai.com/authorize', active: true, windowId: 100 } },
{ type: 'remove', ids: [1] },
]);
assert.deepEqual(tabs, [
{ id: 2, active: true, windowId: 100, url: 'https://auth.openai.com/authorize' },
]);
});
test('tab runtime scopes tab queries to the locked automation window', async () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
+19 -19
View File
@@ -34,9 +34,9 @@ test('operation delay runs action first and waits once after completion', async
assert.deepStrictEqual(events, ['operation:start', 'operation:end', 'sleep:2000']);
});
test('operation delay skips disabled mode and excluded step keys', async () => {
test('operation delay ignores disabled flags but still skips excluded step keys', async () => {
const api = loadOperationDelayApi();
assert.equal(api.shouldDelayOperation({ enabled: false, stepKey: 'fill-profile', kind: 'click' }), false);
assert.equal(api.shouldDelayOperation({ enabled: false, stepKey: 'fill-profile', kind: 'click' }), true);
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'confirm-oauth', kind: 'click' }), false);
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'platform-verify', kind: 'submit' }), false);
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'fill-profile', kind: 'fill' }), true);
@@ -49,7 +49,7 @@ test('operation delay defaults unresolved settings to enabled', async () => {
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('persisted disabled setting prevents first operation delay even before initial restore resolves', async () => {
test('persisted disabled setting is normalized back to enabled before first operation delay resolves', async () => {
const events = [];
let resolveStorage;
const storageReady = new Promise((resolve) => { resolveStorage = resolve; });
@@ -69,10 +69,10 @@ test('persisted disabled setting prevents first operation delay even before init
resolveStorage({ operationDelayEnabled: false });
await pendingOperation;
assert.deepStrictEqual(events, ['operation']);
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('newer disabled storage change wins over older pending restore', async () => {
test('disabled storage changes are normalized back to enabled even when they arrive before restore', async () => {
const events = [];
let changeListener = null;
let resolveStorage;
@@ -88,17 +88,17 @@ test('newer disabled storage change wins over older pending restore', async () =
});
changeListener({ operationDelayEnabled: { newValue: false } }, 'local');
assert.equal(api.getOperationDelayEnabled(), false);
assert.equal(api.getOperationDelayEnabled(), true);
resolveStorage({ operationDelayEnabled: true });
await waitForTimer();
assert.equal(api.getOperationDelayEnabled(), false);
assert.equal(api.getOperationDelayEnabled(), true);
await api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.deepStrictEqual(events, ['operation']);
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('newer enabled storage change wins over older pending restore', async () => {
@@ -130,7 +130,7 @@ test('newer enabled storage change wins over older pending restore', async () =>
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('first operation honors async persisted disabled setting before fallback budget expires', async () => {
test('first operation keeps the delay even when async persisted state says disabled', async () => {
const events = [];
const api = loadOperationDelayApi({
events,
@@ -147,7 +147,7 @@ test('first operation honors async persisted disabled setting before fallback bu
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.deepStrictEqual(events, ['operation']);
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('hanging initial storage restore falls back to enabled delay and returns', async () => {
@@ -167,7 +167,7 @@ test('hanging initial storage restore falls back to enabled delay and returns',
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('explicit disabled metadata does not wait for hanging initial storage restore', async () => {
test('explicit disabled metadata is normalized back to enabled during hanging initial restore', async () => {
const events = [];
const api = loadOperationDelayApi({
events,
@@ -181,7 +181,7 @@ test('explicit disabled metadata does not wait for hanging initial storage resto
);
assert.equal(await resolveOrPending(operation), 'resolved');
assert.deepStrictEqual(events, ['operation']);
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('invalid persisted values still resolve to enabled', async () => {
@@ -190,14 +190,14 @@ test('invalid persisted values still resolve to enabled', async () => {
assert.equal(api.getOperationDelayEnabled(), true);
});
test('shouldDelayOperation uses cached disabled setting by default', async () => {
test('shouldDelayOperation normalizes cached disabled setting back to enabled by default', async () => {
const api = loadOperationDelayApi({ chrome: { storage: { local: { get: async () => ({ operationDelayEnabled: false }) }, onChanged: { addListener() {} } } } });
await api.refreshOperationDelaySetting();
assert.equal(api.getOperationDelayEnabled(), false);
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), false);
assert.equal(api.getOperationDelayEnabled(), true);
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), true);
});
test('storage change to disabled is honored by later delayed operations', async () => {
test('storage change to disabled is normalized back to enabled for later delayed operations', async () => {
const events = [];
let changeListener = null;
const api = loadOperationDelayApi({
@@ -212,14 +212,14 @@ test('storage change to disabled is honored by later delayed operations', async
await api.refreshOperationDelaySetting();
changeListener({ operationDelayEnabled: { newValue: false } }, 'local');
assert.equal(api.getOperationDelayEnabled(), false);
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), false);
assert.equal(api.getOperationDelayEnabled(), true);
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), true);
await api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.deepStrictEqual(events, ['operation']);
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('storage change to enabled is honored by later delayed operations', async () => {
@@ -8,6 +8,7 @@ function createContributionContentService(options = {}) {
const cache = new Map();
const windowObject = {};
let fetchCalls = 0;
const fetchUrls = [];
const localStorage = {
getItem(key) {
@@ -23,7 +24,7 @@ function createContributionContentService(options = {}) {
if (options.cachedSnapshot) {
cache.set(
'multipage-contribution-content-summary-v1',
options.cacheKey || 'multipage-contribution-content-summary-v2:openai:cpa',
JSON.stringify(options.cachedSnapshot)
);
}
@@ -44,6 +45,7 @@ function createContributionContentService(options = {}) {
const wrappedFetch = async (...args) => {
fetchCalls += 1;
fetchUrls.push(String(args[0] || ''));
return fetchImpl(...args);
};
@@ -69,11 +71,14 @@ function createContributionContentService(options = {}) {
getFetchCalls() {
return fetchCalls;
},
getFetchUrls() {
return fetchUrls.slice();
},
};
}
test('getContentUpdateSnapshot returns a prompt version for visible contribution content updates', async () => {
const { api } = createContributionContentService({
const { api, getFetchUrls } = createContributionContentService({
fetchImpl: async () => ({
ok: true,
async json() {
@@ -100,10 +105,13 @@ test('getContentUpdateSnapshot returns a prompt version for visible contribution
}),
});
const snapshot = await api.getContentUpdateSnapshot();
const snapshot = await api.getContentUpdateSnapshot({ flowId: 'kiro', targetId: 'kiro-rs' });
assert.equal(snapshot.status, 'update-available');
assert.equal(snapshot.promptVersion, 'auto_run_notice:2026-04-21T12:05:00Z');
assert.equal(snapshot.flowId, 'kiro');
assert.equal(snapshot.targetId, 'kiro-rs');
assert.equal(getFetchUrls()[0], 'https://flowpilot.qlhazycoder.top/api/content-summary?flow=kiro&target=kiro-rs');
assert.equal(snapshot.hasVisibleUpdates, true);
assert.equal(snapshot.latestUpdatedAt, '2026-04-21T12:05:00Z');
assert.equal(snapshot.items.length, 1);
@@ -132,7 +140,7 @@ test('getContentUpdateSnapshot falls back to cached snapshot when the live reque
},
],
portalUrl: 'https://flowpilot.qlhazycoder.top',
apiUrl: 'https://flowpilot.qlhazycoder.top/api/content-summary',
apiUrl: 'https://flowpilot.qlhazycoder.top/api/content-summary?flow=openai&target=cpa',
checkedAt: Date.now() - 1000,
};
@@ -151,3 +159,35 @@ test('getContentUpdateSnapshot falls back to cached snapshot when the live reque
assert.equal(snapshot.errorMessage, 'offline');
assert.equal(snapshot.items[0].slug, 'announcement');
});
test('getContentUpdateSnapshot keeps flow caches isolated', async () => {
const cachedSnapshot = {
status: 'update-available',
promptVersion: 'flow:kiro|target:kiro-rs|auto_run_notice:2026-04-22T00:00:00Z',
hasVisibleUpdates: true,
latestUpdatedAt: '2026-04-22T00:00:00Z',
latestUpdatedAtDisplay: '2026-04-22 08:00',
flowId: 'kiro',
targetId: 'kiro-rs',
items: [{ slug: 'auto_run_notice', isVisible: true, text: 'Kiro 提示' }],
checkedAt: Date.now() - 1000,
};
const { api } = createContributionContentService({
cachedSnapshot,
cacheKey: 'multipage-contribution-content-summary-v2:kiro:kiro-rs',
fetchImpl: async () => {
throw new Error('offline');
},
});
const openAiSnapshot = await api.getContentUpdateSnapshot({ flowId: 'openai', targetId: 'cpa' });
const kiroSnapshot = await api.getContentUpdateSnapshot({ flowId: 'kiro', targetId: 'kiro-rs' });
assert.equal(openAiSnapshot.status, 'error');
assert.equal(openAiSnapshot.fromCache, undefined);
assert.equal(openAiSnapshot.flowId, 'openai');
assert.equal(kiroSnapshot.fromCache, true);
assert.equal(kiroSnapshot.flowId, 'kiro');
assert.equal(kiroSnapshot.promptVersion, cachedSnapshot.promptVersion);
});
+79
View File
@@ -0,0 +1,79 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const flowRegistrySource = fs.readFileSync('shared/flow-registry.js', 'utf8');
const contributionRegistrySource = fs.readFileSync('shared/contribution-registry.js', 'utf8');
function loadApi() {
const scope = {};
return new Function(
'self',
`${flowRegistrySource}; ${contributionRegistrySource}; return self.MultiPageContributionRegistry;`
)(scope);
}
test('contribution registry exposes OpenAI and Kiro adapters through one contract', () => {
const api = loadApi();
assert.deepEqual(
api.getContributionAdapterIds('openai'),
['openai-oauth', 'openai-codex-file', 'openai-sub2api-file']
);
assert.deepEqual(api.getContributionAdapterIds('kiro'), ['kiro-builder-id']);
assert.equal(api.getDefaultContributionAdapterId('openai'), 'openai-oauth');
assert.equal(api.getDefaultContributionAdapterId('kiro'), 'kiro-builder-id');
assert.equal(api.getAdapterDefinition('kiro-builder-id')?.artifactKind, 'kiro-builder-id');
assert.equal(api.getAdapterDefinition('openai-oauth')?.flowId, 'openai');
assert.equal(api.hasContributionAdapter('kiro', 'kiro-builder-id'), true);
assert.equal(api.hasContributionAdapter('kiro', 'openai-oauth'), false);
});
test('contribution registry resolves the combined tutorial entry per flow', () => {
const api = loadApi();
assert.deepEqual(
api.getContributionTutorialEntry('openai', {
portalBaseUrl: 'https://flowpilot.example/root/',
targetId: 'sub2api',
}),
{
id: 'openai-contribution-tutorial',
flowId: 'openai',
label: '贡献/使用教程',
portalPath: '/tutorial',
defaultTargetId: 'cpa',
contributionAdapterId: 'openai-oauth',
action: 'open-portal-and-enable-contribution',
targetId: 'sub2api',
portalUrl: 'https://flowpilot.example/root/tutorial?flow=openai&target=sub2api',
}
);
assert.deepEqual(
api.getContributionTutorialEntry('kiro', {
portalBaseUrl: 'https://flowpilot.example',
}),
{
id: 'kiro-contribution-tutorial',
flowId: 'kiro',
label: '贡献/使用教程',
portalPath: '/tutorial',
defaultTargetId: 'kiro-rs',
contributionAdapterId: 'kiro-builder-id',
action: 'open-portal-and-enable-contribution',
targetId: 'kiro-rs',
portalUrl: 'https://flowpilot.example/tutorial?flow=kiro&target=kiro-rs',
}
);
});
test('contribution registry fails fast when a published flow has no adapter', () => {
const api = loadApi();
assert.equal(api.assertPublishedFlowsHaveContributionAdapters(['openai', 'kiro']), true);
assert.throws(
() => api.assertPublishedFlowsHaveContributionAdapters(['openai', 'missing-flow']),
/缺少账号贡献适配器:missing-flow/
);
});
+125 -19
View File
@@ -2,11 +2,17 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const flowRegistrySource = fs.readFileSync('shared/flow-registry.js', 'utf8');
const contributionRegistrySource = fs.readFileSync('shared/contribution-registry.js', 'utf8');
const settingsSchemaSource = fs.readFileSync('shared/settings-schema.js', 'utf8');
const source = fs.readFileSync('shared/flow-capabilities.js', 'utf8');
function loadApi() {
const scope = {};
return new Function('self', `${source}; return self.MultiPageFlowCapabilities;`)(scope);
return new Function(
'self',
`${flowRegistrySource}; ${contributionRegistrySource}; ${settingsSchemaSource}; ${source}; return self.MultiPageFlowCapabilities;`
)(scope);
}
test('flow capability registry keeps OpenAI phone signup available only when runtime locks allow it', () => {
@@ -16,10 +22,10 @@ test('flow capability registry keeps OpenAI phone signup available only when run
const enabledState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'openai',
panelMode: 'cpa',
openaiIntegrationTargetId: 'cpa',
phoneVerificationEnabled: true,
plusModeEnabled: false,
contributionMode: false,
accountContributionEnabled: false,
signupMethod: 'phone',
},
});
@@ -32,10 +38,10 @@ test('flow capability registry keeps OpenAI phone signup available only when run
const plusLockedState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'openai',
panelMode: 'sub2api',
openaiIntegrationTargetId: 'sub2api',
phoneVerificationEnabled: true,
plusModeEnabled: true,
contributionMode: false,
accountContributionEnabled: false,
signupMethod: 'phone',
},
});
@@ -53,10 +59,10 @@ test('flow capability registry defaults unknown flows to minimal non-phone capab
const capabilityState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'site-a',
panelMode: 'codex2api',
openaiIntegrationTargetId: 'codex2api',
phoneVerificationEnabled: true,
plusModeEnabled: true,
contributionMode: true,
accountContributionEnabled: true,
signupMethod: 'phone',
},
});
@@ -68,17 +74,45 @@ test('flow capability registry defaults unknown flows to minimal non-phone capab
assert.equal(capabilityState.canUsePhoneSignup, false);
assert.equal(capabilityState.effectiveSignupMethod, 'email');
assert.equal(capabilityState.panelMode, 'codex2api');
assert.deepEqual(capabilityState.supportedPanelModes, []);
assert.deepEqual(capabilityState.supportedTargetIds, []);
});
test('flow capability registry exposes shared auto-run validation for phone locks and panel support', () => {
test('flow capability registry exposes Kiro as an independent flow with its own visible groups', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
const capabilityState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'kiro',
kiroTargetId: 'kiro-rs',
openaiIntegrationTargetId: 'sub2api',
signupMethod: 'phone',
plusModeEnabled: true,
phoneVerificationEnabled: true,
},
});
assert.equal(capabilityState.activeFlowId, 'kiro');
assert.equal(capabilityState.canShowPhoneSettings, false);
assert.equal(capabilityState.canShowPlusSettings, false);
assert.equal(capabilityState.canShowContributionMode, true);
assert.equal(capabilityState.effectiveSignupMethod, 'email');
assert.equal(capabilityState.effectiveTargetId, 'kiro-rs');
assert.deepEqual(capabilityState.flowCapabilities.contributionAdapterIds, ['kiro-builder-id']);
assert.deepEqual(
capabilityState.visibleGroupIds,
['kiro-runtime-status', 'kiro-target-kiro-rs', 'service-account', 'service-email', 'service-proxy']
);
});
test('flow capability registry exposes shared auto-run validation for phone locks and target support', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry({
flowCapabilities: {
openai: api.FLOW_CAPABILITIES.openai,
'site-a': {
...api.DEFAULT_FLOW_CAPABILITIES,
supportsPlatformBinding: ['cpa'],
supportedTargetIds: ['cpa'],
},
},
});
@@ -86,11 +120,11 @@ test('flow capability registry exposes shared auto-run validation for phone lock
const plusLockedResult = registry.validateAutoRunStart({
state: {
activeFlowId: 'openai',
panelMode: 'cpa',
openaiIntegrationTargetId: 'cpa',
signupMethod: 'phone',
phoneVerificationEnabled: true,
plusModeEnabled: true,
contributionMode: false,
accountContributionEnabled: false,
},
});
@@ -100,7 +134,7 @@ test('flow capability registry exposes shared auto-run validation for phone lock
const unsupportedPanelResult = registry.validateAutoRunStart({
state: {
activeFlowId: 'site-a',
panelMode: 'sub2api',
openaiIntegrationTargetId: 'sub2api',
signupMethod: 'email',
},
});
@@ -116,7 +150,7 @@ test('flow capability registry normalizes unsupported mode switches back to the
openai: api.FLOW_CAPABILITIES.openai,
'site-a': {
...api.DEFAULT_FLOW_CAPABILITIES,
supportsPlatformBinding: ['cpa'],
supportedTargetIds: ['cpa'],
},
},
});
@@ -124,28 +158,31 @@ test('flow capability registry normalizes unsupported mode switches back to the
const validation = registry.validateModeSwitch({
state: {
activeFlowId: 'site-a',
panelMode: 'sub2api',
openaiIntegrationTargetId: 'sub2api',
signupMethod: 'phone',
phoneVerificationEnabled: true,
plusModeEnabled: true,
contributionMode: true,
accountContributionEnabled: true,
},
changedKeys: [
'panelMode',
'openaiIntegrationTargetId',
'signupMethod',
'phoneVerificationEnabled',
'plusModeEnabled',
'contributionMode',
'accountContributionEnabled',
],
});
assert.equal(validation.ok, false);
assert.deepEqual(validation.normalizedUpdates, {
panelMode: 'cpa',
openaiIntegrationTargetId: 'cpa',
kiroTargetId: 'cpa',
signupMethod: 'email',
phoneVerificationEnabled: false,
plusModeEnabled: false,
contributionMode: false,
accountContributionEnabled: false,
accountContributionEnabled: false,
});
assert.deepEqual(
validation.errors.map((entry) => entry.code),
@@ -158,3 +195,72 @@ test('flow capability registry normalizes unsupported mode switches back to the
]
);
});
test('flow capability registry exposes editable Plus account access strategies for SUB2API', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
const capabilityState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'openai',
openaiIntegrationTargetId: 'sub2api',
signupMethod: 'email',
plusModeEnabled: true,
plusAccountAccessStrategy: 'sub2api_codex_session',
},
});
assert.deepEqual(
capabilityState.availablePlusAccountAccessStrategies,
['oauth', '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 exposes editable Plus account access strategies for CPA', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
const capabilityState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'openai',
openaiIntegrationTargetId: 'cpa',
signupMethod: 'email',
plusModeEnabled: true,
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, 'cpa_codex_session');
assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'oauth');
assert.equal(capabilityState.canEditPlusAccountAccessStrategy, false);
assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'oauth');
});
+123
View File
@@ -0,0 +1,123 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const flowRegistrySource = fs.readFileSync('shared/flow-registry.js', 'utf8');
const settingsSchemaSource = fs.readFileSync('shared/settings-schema.js', 'utf8');
function loadApis() {
const scope = {};
return new Function('self', `${flowRegistrySource}; ${settingsSchemaSource}; return {
flowRegistry: self.MultiPageFlowRegistry,
settingsSchema: self.MultiPageSettingsSchema,
};`)(scope);
}
test('flow registry exposes canonical flow and target metadata', () => {
const { flowRegistry } = loadApis();
assert.deepEqual(flowRegistry.getRegisteredFlowIds(), ['openai', 'kiro']);
assert.equal(flowRegistry.normalizeFlowId('kiro'), 'kiro');
assert.equal(flowRegistry.normalizeFlowId('unknown'), 'openai');
assert.equal(flowRegistry.getFlowLabel('openai'), 'Codex / OpenAI');
assert.equal(flowRegistry.normalizeTargetId('openai', 'sub2api'), 'sub2api');
assert.equal(flowRegistry.normalizeTargetId('kiro', 'anything-else'), 'kiro-rs');
assert.deepEqual(
flowRegistry.getVisibleGroupIds('openai', 'cpa'),
['openai-plus', 'openai-phone', 'openai-oauth', 'openai-step6', 'openai-target-cpa', 'service-account', 'service-email', 'service-proxy']
);
assert.deepEqual(
flowRegistry.getVisibleGroupIds('kiro', 'kiro-rs'),
['kiro-runtime-status', 'kiro-target-kiro-rs', 'service-account', 'service-email', 'service-proxy']
);
assert.deepEqual(
flowRegistry.getTargetOptions('openai').map((entry) => entry.id),
['cpa', 'sub2api', 'codex2api']
);
assert.deepEqual(
flowRegistry.getSettingsGroupDefinition('openai-plus')?.rowIds,
['row-plus-mode', 'row-plus-account-access-strategy', 'row-plus-payment-method']
);
assert.equal(flowRegistry.getPublicationTargetDefinition('kiro', 'kiro-rs')?.label, 'kiro.rs');
assert.equal(flowRegistry.getFlowCapabilities('openai').supportsAccountContribution, true);
assert.equal(flowRegistry.getFlowCapabilities('kiro').supportsAccountContribution, true);
assert.deepEqual(
flowRegistry.getFlowCapabilities('openai').contributionAdapterIds,
['openai-oauth', 'openai-codex-file', 'openai-sub2api-file']
);
assert.deepEqual(
flowRegistry.getFlowCapabilities('kiro').contributionAdapterIds,
['kiro-builder-id']
);
});
test('settings schema normalizes view input into canonical nested namespaces', () => {
const { settingsSchema } = loadApis();
const schema = settingsSchema.createSettingsSchema();
const normalized = schema.normalizeSettingsState({
activeFlowId: 'kiro',
panelMode: 'sub2api',
mailProvider: 'hotmail',
ipProxyEnabled: true,
ipProxyService: '711proxy',
customPassword: 'SharedSecret123!',
plusAccountAccessStrategy: 'sub2api_codex_session',
kiroRsUrl: 'https://kiro.example.com/admin',
kiroRsKey: 'secret-key',
stepExecutionRangeByFlow: {
openai: { enabled: true, fromStep: 2, toStep: 9 },
kiro: { enabled: true, fromStep: 1, toStep: 9 },
},
});
assert.equal(normalized.activeFlowId, 'kiro');
assert.equal(normalized.services.email.provider, 'hotmail');
assert.equal(normalized.services.proxy.enabled, true);
assert.equal(normalized.services.account.customPassword, 'SharedSecret123!');
assert.equal(normalized.flows.openai.integrationTargetId, 'sub2api');
assert.equal(normalized.flows.openai.plus.plusAccountAccessStrategy, 'sub2api_codex_session');
assert.equal(normalized.flows.kiro.targetId, 'kiro-rs');
assert.equal(normalized.flows.kiro.targets['kiro-rs'].baseUrl, 'https://kiro.example.com/admin');
assert.equal(normalized.flows.kiro.targets['kiro-rs'].apiKey, 'secret-key');
assert.deepEqual(normalized.flows.kiro.autoRun.stepExecutionRange, {
enabled: true,
fromStep: 1,
toStep: 9,
});
});
test('settings schema can project canonical state into a read view without legacy rebuild helpers', () => {
const { settingsSchema } = loadApis();
const schema = settingsSchema.createSettingsSchema();
const normalized = schema.normalizeSettingsState({
activeFlowId: 'kiro',
kiroTargetId: 'kiro-rs',
kiroRsUrl: 'https://kiro.example.com/admin',
kiroRsKey: 'key-123',
plusAccountAccessStrategy: 'sub2api_codex_session',
});
const view = schema.buildSettingsView(normalized);
assert.equal(view.activeFlowId, 'kiro');
assert.equal(view.openaiIntegrationTargetId, 'cpa');
assert.equal(view.kiroTargetId, 'kiro-rs');
assert.equal(view.panelMode, 'cpa');
assert.equal(view.kiroRsUrl, 'https://kiro.example.com/admin');
assert.equal(view.kiroRsKey, 'key-123');
assert.equal(view.plusAccountAccessStrategy, 'sub2api_codex_session');
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');
});
+106 -2
View File
@@ -6,7 +6,7 @@ const source = fs.readFileSync('background/steps/gopay-manual-confirm.js', 'utf8
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundGoPayManualConfirm;`)(globalScope);
test('GoPay manual confirm executor publishes pending manual confirmation state for step 7', async () => {
test('GoPay manual confirm executor publishes pending manual confirmation state for OAuth continuation', async () => {
const stateUpdates = [];
const broadcasts = [];
const events = [];
@@ -24,6 +24,13 @@ test('GoPay manual confirm executor publishes pending manual confirmation state
},
},
},
getNodeIdsForState: () => [
'open-chatgpt',
'plus-checkout-create',
'plus-checkout-billing',
'gopay-subscription-confirm',
'oauth-login',
],
getTabId: async () => 42,
isTabAlive: async () => true,
registerTab: async () => {},
@@ -32,17 +39,114 @@ test('GoPay manual confirm executor publishes pending manual confirmation state
},
});
await executor.executeGoPayManualConfirm({ plusCheckoutTabId: 42, plusCheckoutUrl: 'https://chatgpt.com/checkout/openai_llc/session' });
await executor.executeGoPayManualConfirm({
nodeId: 'gopay-subscription-confirm',
plusCheckoutTabId: 42,
plusCheckoutUrl: 'https://chatgpt.com/checkout/openai_llc/session',
});
assert.equal(stateUpdates.length, 1);
assert.equal(stateUpdates[0].plusManualConfirmationPending, true);
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.equal(broadcasts.length, 1);
assert.equal(broadcasts[0].plusManualConfirmationPending, true);
assert.deepStrictEqual(
events.find((event) => event.type === 'tab-update'),
{ type: 'tab-update', tabId: 42, payload: { active: true } }
);
assert.match(
events.find((event) => event.type === 'log')?.message || '',
/OAuth/
);
});
test('GoPay manual confirm executor switches continuation copy to SUB2API session import when the effective tail is 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',
'sub2api-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, /SUB2API/);
assert.match(
events.find((event) => event.type === 'log')?.message || '',
/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/
);
});
+219
View File
@@ -0,0 +1,219 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadKiroContributionModules() {
const stateSource = fs.readFileSync('background/kiro/state.js', 'utf8');
const artifactSource = fs.readFileSync('background/kiro/credential-artifact.js', 'utf8');
const adapterSource = fs.readFileSync('background/contribution/adapters/kiro-builder-id.js', 'utf8');
const globalScope = {};
new Function('self', `${stateSource}; ${artifactSource}; ${adapterSource}; return self;`)(globalScope);
return globalScope;
}
function buildAuthorizedKiroState(overrides = {}) {
return {
activeFlowId: 'kiro',
flowId: 'kiro',
accountContributionEnabled: true,
contributionAdapterId: 'kiro-builder-id',
contributionNickname: '贡献者',
contributionQq: '123456',
kiroRuntime: {
register: {
email: 'kiro-user@example.com',
},
desktopAuth: {
region: 'us-east-1',
clientId: 'client-id-001',
clientSecret: 'client-secret-super-long',
refreshToken: 'refresh-token-super-secret',
tokenSource: 'desktop_authorization_code_pkce',
authorizedAt: 1760000000000,
},
upload: {
targetId: 'kiro-rs',
},
},
...overrides,
};
}
test('Kiro Builder ID artifact builder validates and builds unified contribution artifact', () => {
const scope = loadKiroContributionModules();
const api = scope.MultiPageBackgroundKiroCredentialArtifact;
const artifact = api.buildKiroBuilderIdArtifact(buildAuthorizedKiroState());
assert.equal(artifact.flow, 'kiro');
assert.equal(artifact.adapter, 'kiro-builder-id');
assert.equal(artifact.artifact, 'kiro-builder-id');
assert.equal(artifact.account.email, 'kiro-user@example.com');
assert.equal(artifact.credentials.refreshToken, 'refresh-token-super-secret');
assert.equal(artifact.credentials.clientId, 'client-id-001');
assert.equal(artifact.credentials.clientSecret, 'client-secret-super-long');
assert.equal(artifact.credentials.region, 'us-east-1');
assert.equal(artifact.metadata.targetId, 'kiro-rs');
const safeSummary = api.buildSafeArtifactSummary(artifact);
assert.equal(safeSummary.refreshToken.includes('refresh-token-super-secret'), false);
assert.equal(safeSummary.clientSecret.includes('client-secret-super-long'), false);
});
test('Kiro Builder ID artifact builder rejects missing required fields', () => {
const scope = loadKiroContributionModules();
const api = scope.MultiPageBackgroundKiroCredentialArtifact;
assert.throws(
() => api.buildKiroBuilderIdArtifact(buildAuthorizedKiroState({
kiroRuntime: {
...buildAuthorizedKiroState().kiroRuntime,
desktopAuth: {
...buildAuthorizedKiroState().kiroRuntime.desktopAuth,
refreshToken: '',
},
},
})),
/refreshToken/
);
assert.throws(
() => api.buildKiroBuilderIdArtifact(buildAuthorizedKiroState({
kiroRuntime: {
...buildAuthorizedKiroState().kiroRuntime,
desktopAuth: {
...buildAuthorizedKiroState().kiroRuntime.desktopAuth,
clientId: '',
},
},
})),
/clientId/
);
assert.throws(
() => api.buildKiroBuilderIdArtifact(buildAuthorizedKiroState({
kiroRuntime: {
...buildAuthorizedKiroState().kiroRuntime,
desktopAuth: {
...buildAuthorizedKiroState().kiroRuntime.desktopAuth,
clientSecret: '',
},
},
})),
/clientSecret/
);
assert.throws(
() => api.buildKiroBuilderIdArtifact(buildAuthorizedKiroState({
kiroRuntime: {
...buildAuthorizedKiroState().kiroRuntime,
register: { email: '' },
},
email: '',
accountIdentifier: '',
})),
/注册邮箱/
);
});
test('Kiro contribution adapter submits public contribution without requiring kiro.rs config and redacts logs', async () => {
const scope = loadKiroContributionModules();
const adapterApi = scope.MultiPageBackgroundKiroBuilderIdContributionAdapter;
const fetchCalls = [];
const logs = [];
const statePatches = [];
const adapter = adapterApi.createKiroBuilderIdContributionAdapter({
addLog: async (message, level, options) => logs.push({ message, level, options }),
fetchImpl: async (url, options) => {
fetchCalls.push({ url, options });
return {
ok: true,
status: 200,
async text() {
return JSON.stringify({ ok: true, contribution_id: 'kiro-contribution-001', message: '已接收' });
},
};
},
setState: async (patch) => statePatches.push(patch),
});
const result = await adapter.maybeSubmitFlowContribution(buildAuthorizedKiroState({
kiroRsUrl: '',
kiroRsKey: '',
}), { nodeId: 'kiro-complete-desktop-authorize' });
assert.equal(result.ok, true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://flowpilot.qlhazycoder.top/api/contributions');
const requestBody = JSON.parse(fetchCalls[0].options.body);
assert.equal(requestBody.flow, 'kiro');
assert.equal(requestBody.adapter_id, 'kiro-builder-id');
assert.equal(requestBody.artifact_kind, 'kiro-builder-id');
assert.equal(requestBody.artifact.credentials.refreshToken, 'refresh-token-super-secret');
assert.equal(statePatches.at(-1).flowContributionRuntime.kiro.status, 'submitted');
const logText = logs.map((entry) => entry.message).join('\n');
assert.equal(logText.includes('refresh-token-super-secret'), false);
assert.equal(logText.includes('client-secret-super-long'), false);
});
test('Kiro contribution adapter skips invalid artifacts without sending secrets', async () => {
const scope = loadKiroContributionModules();
const adapterApi = scope.MultiPageBackgroundKiroBuilderIdContributionAdapter;
const fetchCalls = [];
const logs = [];
const adapter = adapterApi.createKiroBuilderIdContributionAdapter({
addLog: async (message) => logs.push(message),
fetchImpl: async () => {
fetchCalls.push(true);
throw new Error('should not be called');
},
setState: async () => {},
});
const result = await adapter.maybeSubmitFlowContribution(buildAuthorizedKiroState({
kiroRuntime: {
...buildAuthorizedKiroState().kiroRuntime,
desktopAuth: {
...buildAuthorizedKiroState().kiroRuntime.desktopAuth,
refreshToken: '',
},
},
}));
assert.equal(result.skipped, true);
assert.equal(result.reason, 'missing_refreshToken');
assert.equal(fetchCalls.length, 0);
assert.equal(logs.join('\n').includes('refresh-token-super-secret'), false);
});
test('Kiro contribution adapter redacts server errors that echo submitted secrets', async () => {
const scope = loadKiroContributionModules();
const adapterApi = scope.MultiPageBackgroundKiroBuilderIdContributionAdapter;
const logs = [];
const statePatches = [];
const adapter = adapterApi.createKiroBuilderIdContributionAdapter({
addLog: async (message) => logs.push(message),
fetchImpl: async () => ({
ok: false,
status: 400,
async text() {
return JSON.stringify({
ok: false,
message: 'bad refresh-token-super-secret and client-secret-super-long',
});
},
}),
setState: async (patch) => statePatches.push(patch),
});
const result = await adapter.maybeSubmitFlowContribution(buildAuthorizedKiroState());
assert.equal(result.ok, false);
const combined = `${logs.join('\n')}\n${JSON.stringify(statePatches)}`;
assert.equal(combined.includes('refresh-token-super-secret'), false);
assert.equal(combined.includes('client-secret-super-long'), false);
});
test('Kiro public contribution is triggered from step 9 instead of step 8', () => {
const desktopSource = fs.readFileSync('background/kiro/desktop-authorize-runner.js', 'utf8');
const publisherSource = fs.readFileSync('background/kiro/publisher-kiro-rs.js', 'utf8');
assert.doesNotMatch(desktopSource, /trigger:\s*'kiro-step-8'/);
assert.match(publisherSource, /maybeSubmitFlowContribution/);
assert.match(publisherSource, /trigger:\s*'kiro-step-9'/);
});
@@ -0,0 +1,247 @@
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/kiro/register-page.js', 'utf8');
function createTextNode(textContent = '') {
return { textContent };
}
function createInputElement({
id = '',
type = 'text',
placeholder = '',
label = '',
value = '',
} = {}) {
const attributes = {
id,
type,
placeholder,
autocomplete: 'off',
};
return {
tagName: 'INPUT',
id,
type,
value,
labels: label ? [createTextNode(label)] : [],
disabled: false,
readOnly: false,
form: null,
getAttribute(name) {
return attributes[name] || '';
},
getBoundingClientRect() {
return { width: 455, height: 32 };
},
closest(selector) {
if (selector === 'label') {
return null;
}
return null;
},
};
}
function createButtonElement({ text = 'Continue', testId = 'test-primary-button' } = {}) {
return {
tagName: 'BUTTON',
textContent: text,
value: '',
disabled: false,
form: null,
getAttribute(name) {
if (name === 'data-testid') return testId;
if (name === 'type') return 'submit';
return '';
},
getBoundingClientRect() {
return { width: 455, height: 32 };
},
closest() {
return null;
},
};
}
function createHarness({ href, hostname, title = '', bodyText = '' }) {
return createDomHarness({ href, hostname, title, bodyText });
}
function createDomHarness({
href,
hostname,
title = '',
bodyText = '',
inputs = [],
buttons = [],
} = {}) {
const elementsById = new Map();
for (const element of [...inputs, ...buttons]) {
if (element.id) {
elementsById.set(element.id, element);
}
}
const context = {
console: { log() {}, warn() {}, error() {}, info() {} },
location: { href, hostname },
document: {
title,
body: {
textContent: bodyText,
},
documentElement: {
getAttribute() {
return '1';
},
setAttribute() {},
},
getElementById(id) {
return elementsById.get(id) || null;
},
querySelector() {
return null;
},
querySelectorAll(selector) {
if (String(selector).startsWith('label[for=')) {
return [];
}
if (String(selector).trim().startsWith('input')) {
return inputs;
}
if (String(selector).includes('button') || String(selector).includes('[role="button"]')) {
return buttons;
}
return [];
},
},
window: {},
globalThis: null,
resetStopState() {},
isStopError() {
return false;
},
throwIfStopped() {},
sleep() {
return Promise.resolve();
},
fillInput(element, value) {
element.value = value;
},
MouseEvent: class {},
PointerEvent: class {},
KeyboardEvent: class {},
Event: class {},
};
context.window = context;
context.globalThis = context;
context.window.getComputedStyle = () => ({ display: 'block', visibility: 'visible' });
context.CSS = { escape: (value) => String(value) };
vm.createContext(context);
vm.runInContext(source, context);
return context;
}
test('kiro register content detects aws request error page as a proxy failure', () => {
const harness = createHarness({
href: 'https://profile.aws.amazon.com/signup',
hostname: 'profile.aws.amazon.com',
title: '错误',
bodyText: '抱歉,处理您的请求时出错。请重试。',
});
const detected = harness.detectKiroRegisterPageState();
assert.equal(detected.state, 'proxy_error_page');
assert.match(detected.fatalMessage, /切换代理/);
});
test('kiro register content does not misclassify the normal name page as a proxy failure', () => {
const harness = createHarness({
href: 'https://profile.aws.amazon.com/signup',
hostname: 'profile.aws.amazon.com',
title: 'Enter your name',
bodyText: '输入您的姓名 继续',
});
const fatal = harness.detectKiroFatalPageState('输入您的姓名 继续', harness.location.href, harness.document.title);
assert.equal(fatal, null);
});
test('kiro register content treats Kiro web success callback as signed in', () => {
const harness = createHarness({
href: 'https://app.kiro.dev/signin?auth_status=success&redirect_from=KiroIDE',
hostname: 'app.kiro.dev',
title: 'Kiro',
bodyText: 'Signed in',
});
const detected = harness.detectKiroRegisterPageState();
assert.equal(detected.state, 'kiro_web_signed_in');
});
test('kiro register content extracts signed-in account email from Kiro account page text', () => {
const harness = createHarness({
href: 'https://app.kiro.dev/settings/account',
hostname: 'app.kiro.dev',
title: 'Account',
bodyText: 'Account Email scrap-aged-quirk@duck.com support@kiro.dev',
});
const detected = harness.detectKiroRegisterPageState();
assert.equal(detected.state, 'kiro_web_signed_in');
assert.equal(detected.accountEmail, 'scrap-aged-quirk@duck.com');
assert.equal(detected.email, 'scrap-aged-quirk@duck.com');
});
test('kiro register content fills primary and confirm password fields separately', async () => {
const passwordInput = createInputElement({
id: 'formField15-1779204320095-7559',
type: 'text',
placeholder: 'Enter password',
label: '\u5bc6\u7801',
});
const confirmPasswordInput = createInputElement({
id: 'formField16-1779204320096-1309',
type: 'text',
placeholder: 'Re-enter password',
label: '\u786e\u8ba4\u5bc6\u7801',
});
const checkboxInput = createInputElement({
id: '17-1779204320097-4334',
type: 'checkbox',
label: '\u663e\u793a\u5bc6\u7801',
value: 'on',
});
const continueButton = createButtonElement();
const clicks = [];
const harness = createDomHarness({
href: 'https://us-east-1.signin.aws/platform/d-9067642ac7/signup',
hostname: 'us-east-1.signin.aws',
title: 'Amazon Web Services',
bodyText: 'Create your password',
inputs: [passwordInput, confirmPasswordInput, checkboxInput],
buttons: [continueButton],
});
harness.simulateClick = (element) => clicks.push(element);
const detected = harness.detectKiroRegisterPageState();
assert.equal(detected.state, 'password_page');
assert.equal(detected.passwordInput, passwordInput);
assert.equal(detected.confirmPasswordInput, confirmPasswordInput);
const result = await harness.submitKiroPassword({ password: 'mdy8U9_rzqhw6D' });
assert.equal(result.state, 'password_submitted');
assert.equal(passwordInput.value, 'mdy8U9_rzqhw6D');
assert.equal(confirmPasswordInput.value, 'mdy8U9_rzqhw6D');
assert.deepEqual(clicks, [continueButton]);
});
@@ -0,0 +1,138 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
function createExecutor(overrides = {}) {
const events = {
logs: [],
completed: [],
fetchCalls: [],
};
const executor = api.createStep10Executor({
addLog: async (message, level, options) => {
events.logs.push({ message, level, options });
},
chrome: {},
closeConflictingTabsForSource: async () => {},
completeNodeFromBackground: async (nodeId, payload) => {
events.completed.push({ nodeId, payload });
},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'cpa',
getStepIdByKeyForState: (stepKey) => ({
'oauth-login': 7,
'relogin-bound-email': 11,
'confirm-oauth': 14,
'platform-verify': 15,
})[stepKey] || null,
getTabId: async () => 1,
isLocalhostOAuthCallbackUrl: (url) => {
try {
const parsed = new URL(url);
return ['localhost', '127.0.0.1'].includes(parsed.hostname)
&& Boolean(parsed.searchParams.get('code'))
&& Boolean(parsed.searchParams.get('state'));
} catch {
return false;
}
},
isTabAlive: async () => true,
normalizeCodex2ApiUrl: (url) => url,
normalizeSub2ApiUrl: (url) => url,
rememberSourceLastUrl: () => {},
reuseOrCreateTab: async () => 1,
sendToContentScript: async () => ({}),
sendToContentScriptResilient: async () => ({}),
shouldBypassStep9ForLocalCpa: () => false,
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 30000,
...overrides,
});
return { executor, events };
}
test('CPA platform verify resolves relogin tail steps from active definitions', async () => {
const originalFetch = global.fetch;
const { executor, events } = createExecutor();
global.fetch = async (url, options = {}) => {
events.fetchCalls.push({ url, body: JSON.parse(options.body || '{}') });
return {
ok: true,
status: 200,
json: async () => ({ message: 'CPA callback accepted' }),
};
};
try {
await executor.executeStep10({
visibleStep: 15,
nodeId: 'platform-verify',
localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=expected',
cpaOAuthState: 'expected',
cpaManagementOrigin: 'http://127.0.0.1:8317',
vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
vpsPassword: 'secret',
});
} finally {
global.fetch = originalFetch;
}
assert.equal(events.fetchCalls.length, 1);
assert.equal(events.fetchCalls[0].body.redirect_url, 'http://127.0.0.1:8317/codex/callback?code=abc&state=expected');
assert.deepStrictEqual(events.completed, [
{
nodeId: 'platform-verify',
payload: {
localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=expected',
verifiedStatus: 'CPA callback accepted',
},
},
]);
});
test('CPA state mismatch points relogin-enabled flow back to relogin-bound-email step', async () => {
const { executor } = createExecutor();
await assert.rejects(
() => executor.executeStep10({
visibleStep: 15,
nodeId: 'platform-verify',
localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=actual',
cpaOAuthState: 'expected',
cpaManagementOrigin: 'http://127.0.0.1:8317',
vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
vpsPassword: 'secret',
}),
(error) => {
assert.match(error.message, /11/);
assert.doesNotMatch(error.message, /步骤\s*10|姝ラ.*10/);
return true;
}
);
});
test('CPA invalid localhost callback points to the dynamic confirm-oauth step', async () => {
const { executor } = createExecutor({
isLocalhostOAuthCallbackUrl: () => false,
});
await assert.rejects(
() => executor.executeStep10({
visibleStep: 15,
nodeId: 'platform-verify',
localhostUrl: 'notaurl',
cpaManagementOrigin: 'http://127.0.0.1:8317',
vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
vpsPassword: 'secret',
}),
(error) => {
assert.match(error.message, /14/);
assert.doesNotMatch(error.message, /12/);
return true;
}
);
});
+320
View File
@@ -0,0 +1,320 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
const backgroundSource = fs.readFileSync('background.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => sidepanelSource.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 i = start; i < sidepanelSource.length; i += 1) {
const ch = sidepanelSource[i];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < sidepanelSource.length; end += 1) {
const ch = sidepanelSource[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return sidepanelSource.slice(start, end);
}
function buildHarness(capabilityStateSource, stateSource) {
return new Function(`
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
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';
${extractFunction('normalizePlusAccountAccessStrategy')}
${extractFunction('getRequestedPlusAccountAccessStrategy')}
${extractFunction('updatePlusModeUI')}
function normalizePlusPaymentMethod(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'gopay' || normalized === 'gpc-helper' ? normalized : 'paypal';
}
function getSelectedPlusPaymentMethod() {
return normalizePlusPaymentMethod(selectPlusPaymentMethod.value || latestState?.plusPaymentMethod || currentPlusPaymentMethod || 'paypal');
}
function normalizeGpcHelperPhoneModeValue(value = '') {
return String(value || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual';
}
function normalizeGpcOtpChannelValue(value = '') {
return String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp';
}
function isGpcAutoModePermissionDenied() {
return false;
}
function getSelectedPanelMode() {
return latestState?.panelMode || 'cpa';
}
function resolveCurrentSidepanelCapabilities() {
return ${capabilityStateSource};
}
let latestState = ${stateSource};
let currentPlusPaymentMethod = 'paypal';
let currentPlusAccountAccessStrategy = latestState.plusAccountAccessStrategy || 'oauth';
const inputPlusModeEnabled = { checked: true };
const rowPlusMode = { style: { display: '' } };
const rowPlusPaymentMethod = { style: { display: 'none' } };
const rowPlusAccountAccessStrategy = { style: { display: 'none' } };
const rowPayPalAccount = { style: { display: 'none' } };
const selectPlusPaymentMethod = { value: latestState.plusPaymentMethod || 'paypal', style: { display: 'none' } };
const selectPlusAccountAccessStrategy = {
value: latestState.plusAccountAccessStrategy || 'oauth',
disabled: false,
dataset: { requestedValue: latestState.plusAccountAccessStrategy || 'oauth' },
setAttribute(name, value) {
this[name] = value;
},
};
const plusPaymentMethodCaption = { textContent: '' };
const plusAccountAccessStrategyCaption = { textContent: '' };
return {
getRequestedPlusAccountAccessStrategy,
plusAccountAccessStrategyCaption,
rowPlusAccountAccessStrategy,
selectPlusAccountAccessStrategy,
updatePlusModeUI,
};
`)();
}
test('sidepanel keeps requested Plus account strategy while OAuth-only targets force the effective value', () => {
const api = buildHarness(
`{
canShowPlusSettings: true,
runtimeLocks: { plusModeEnabled: true },
canEditPlusAccountAccessStrategy: false,
effectivePlusAccountAccessStrategy: 'oauth',
}`,
`{
activeFlowId: 'openai',
panelMode: 'cpa',
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
}`
);
api.updatePlusModeUI();
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/);
});
test('sidepanel enables SUB2API session strategy selection when the current Plus target supports it', () => {
const api = buildHarness(
`{
canShowPlusSettings: true,
runtimeLocks: { plusModeEnabled: true },
canEditPlusAccountAccessStrategy: true,
effectivePlusAccountAccessStrategy: 'sub2api_codex_session',
}`,
`{
activeFlowId: 'openai',
panelMode: 'sub2api',
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
}`
);
api.updatePlusModeUI();
assert.equal(api.rowPlusAccountAccessStrategy.style.display, '');
assert.equal(api.selectPlusAccountAccessStrategy.disabled, false);
assert.equal(api.selectPlusAccountAccessStrategy.value, 'sub2api_codex_session');
assert.equal(api.getRequestedPlusAccountAccessStrategy(), 'sub2api_codex_session');
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'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getStepDefinitionsForMode'),
extractFunction('getWorkflowNodesForMode'),
extractFunction('rebuildStepDefinitionState'),
extractFunction('syncStepDefinitionsForMode'),
].join('\n');
const api = new Function(`
const calls = [];
const window = {
MultiPageStepDefinitions: {
getSteps(options) {
calls.push({ type: 'getSteps', options });
return [{ id: 10, order: 100, key: 'sub2api-session-import', title: 'session-import' }];
},
getNodes(options) {
calls.push({ type: 'getNodes', options });
return [{ nodeId: 'sub2api-session-import', displayOrder: 10, next: [] }];
},
},
};
let latestState = { activeFlowId: 'openai' };
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;
let currentStepDefinitionFlowId = 'openai';
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const DEFAULT_PLUS_PAYMENT_METHOD = 'paypal';
const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = 'oauth';
const DEFAULT_SIGNUP_METHOD = 'email';
let stepDefinitions = [];
let workflowNodes = [];
let STEP_IDS = [];
let STEP_DEFAULT_STATUSES = {};
let SKIPPABLE_STEPS = new Set();
let NODE_IDS = [];
let NODE_DEFAULT_STATUSES = {};
function getSelectedPlusPaymentMethod() {
return currentPlusPaymentMethod;
}
function renderStepsList() {}
${bundle}
return {
calls,
syncStepDefinitionsForMode,
snapshot() {
return {
currentPlusAccountAccessStrategy,
workflowNodes,
stepDefinitions,
STEP_IDS,
NODE_IDS,
};
},
};
`)();
api.syncStepDefinitionsForMode(true, {
activeFlowId: 'openai',
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
render: true,
});
const snapshot = api.snapshot();
assert.equal(snapshot.currentPlusAccountAccessStrategy, 'sub2api_codex_session');
assert.deepStrictEqual(snapshot.STEP_IDS, [10]);
assert.deepStrictEqual(snapshot.NODE_IDS, ['sub2api-session-import']);
assert.equal(snapshot.stepDefinitions[0]?.key, 'sub2api-session-import');
assert.equal(snapshot.workflowNodes[0]?.nodeId, 'sub2api-session-import');
assert.deepStrictEqual(api.calls, [
{
type: 'getSteps',
options: {
activeFlowId: 'openai',
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
},
},
{
type: 'getNodes',
options: {
activeFlowId: 'openai',
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'sub2api_codex_session',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
},
},
]);
});
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'
);
});
+92
View File
@@ -0,0 +1,92 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => sidepanelSource.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 i = start; i < sidepanelSource.length; i += 1) {
const ch = sidepanelSource[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
let depth = 0;
let end = braceStart;
for (; end < sidepanelSource.length; end += 1) {
const ch = sidepanelSource[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return sidepanelSource.slice(start, end);
}
const bundle = [
extractFunction('sanitizeAutoRunAdUrl'),
extractFunction('parseAutoRunAdSegments'),
extractFunction('getAutoRunAdPlainText'),
].join('\n');
const api = new Function(`
${bundle}
return {
sanitizeAutoRunAdUrl,
parseAutoRunAdSegments,
getAutoRunAdPlainText,
};
`)();
test('sanitizeAutoRunAdUrl only allows http and https links', () => {
assert.equal(api.sanitizeAutoRunAdUrl('https://example.com/docs'), 'https://example.com/docs');
assert.equal(api.sanitizeAutoRunAdUrl('http://example.com/docs'), 'http://example.com/docs');
assert.equal(api.sanitizeAutoRunAdUrl('javascript:alert(1)'), '');
assert.equal(api.sanitizeAutoRunAdUrl('data:text/html,hello'), '');
});
test('parseAutoRunAdSegments converts markdown links into safe link segments', () => {
const segments = api.parseAutoRunAdSegments('查看 [教程](https://example.com/tutorial) 和 [频道](https://example.com/channel)');
assert.deepEqual(segments, [
{ type: 'text', text: '查看 ' },
{ type: 'link', text: '教程', url: 'https://example.com/tutorial' },
{ type: 'text', text: ' 和 ' },
{ type: 'link', text: '频道', url: 'https://example.com/channel' },
]);
assert.equal(api.getAutoRunAdPlainText(segments), '查看 教程 和 频道');
});
test('parseAutoRunAdSegments keeps unsafe markdown links as plain text', () => {
const segments = api.parseAutoRunAdSegments('危险链接 [不要点](javascript:alert(1))');
assert.deepEqual(segments, [
{ type: 'text', text: '危险链接 ' },
{ type: 'text', text: '[不要点](javascript:alert(1))' },
]);
});
@@ -60,9 +60,16 @@ function createApi({
extractFunction('startAutoRunFromCurrentSettings'),
].join('\n');
return new Function(`
return new Function(`
const events = [];
const latestState = { contributionMode: false };
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const latestState = {
accountContributionEnabled: false,
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'cpa',
kiroTargetId: 'kiro-rs',
};
const inputAutoSkipFailures = { checked: false };
const inputContributionNickname = { value: 'tester' };
const inputContributionQq = { value: '123456' };
@@ -71,6 +78,8 @@ const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const btnAutoRun = { disabled: false, innerHTML: '' };
const inputRunCount = { disabled: false };
const selectFlow = { value: latestState.activeFlowId };
const selectPanelMode = { value: latestState.panelMode };
let runCountValue = ${Math.max(1, Number(runCount) || 1)};
let pendingAutoRunStartTotalRuns = 0;
let pendingAutoRunStartExpiresAt = 0;
@@ -103,6 +112,19 @@ async function persistCurrentSettingsForAction() {
}
function getRunCountValue() { return Math.max(1, Number(runCountValue) || 1); }
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
function normalizePanelMode(value = '', fallback = 'cpa') {
return String(value || fallback || 'cpa').trim().toLowerCase() || 'cpa';
}
function getSelectedFlowId(state = latestState) {
return String(selectFlow.value || state.activeFlowId || state.flowId || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase() || DEFAULT_ACTIVE_FLOW_ID;
}
function getSelectedTargetId(flowId = getSelectedFlowId()) {
return String(
flowId === 'kiro'
? (selectPanelMode.value || latestState.kiroTargetId || 'kiro-rs')
: normalizePanelMode(selectPanelMode.value || latestState.panelMode || 'cpa')
).trim().toLowerCase() || (flowId === 'kiro' ? 'kiro-rs' : 'cpa');
}
function shouldOfferAutoModeChoice() { return false; }
async function openAutoStartChoiceDialog() { throw new Error('should not be called'); }
function getFirstUnfinishedStep() { return 1; }
@@ -129,7 +151,7 @@ return {
`)();
}
test('startAutoRunFromCurrentSettings refreshes contribution content hint before starting auto run', async () => {
test('startAutoRunFromCurrentSettings kicks off contribution content refresh without blocking auto run', async () => {
const api = createApi();
const result = await api.startAutoRunFromCurrentSettings();
@@ -153,10 +175,24 @@ test('startAutoRunFromCurrentSettings continues auto run when contribution conte
assert.equal(result, true);
assert.deepEqual(
events.map((entry) => entry.type),
['refresh', 'warn', 'sync-settings', 'send']
['refresh', 'sync-settings', 'send', 'warn']
);
assert.equal(events[2].message.type, 'AUTO_RUN');
assert.match(String(events[3].args[0]), /Failed to refresh contribution content hint before auto run/);
});
test('startAutoRunFromCurrentSettings does not wait for a hanging contribution content refresh', async () => {
const api = createApi({
refreshImpl: '() => new Promise(() => {})',
});
const result = await api.startAutoRunFromCurrentSettings();
assert.equal(result, true);
assert.deepEqual(
api.getEvents().map((entry) => entry.type),
['refresh', 'sync-settings', 'send']
);
assert.match(String(events[1].args[0]), /Failed to refresh contribution content hint before auto run/);
assert.equal(events[3].message.type, 'AUTO_RUN');
});
test('startAutoRunFromCurrentSettings does not block auto run when contribution content has updates', async () => {
@@ -196,6 +232,26 @@ test('startAutoRunFromCurrentSettings freezes run count before async settings sy
assert.equal(events[3].message.payload.totalRuns, 20);
});
test('startAutoRunFromCurrentSettings sends current flow selection with auto run payload', async () => {
const api = createApi({
persistImpl: `(events) => {
selectFlow.value = 'kiro';
selectPanelMode.value = 'kiro-rs';
latestState.activeFlowId = 'openai';
latestState.flowId = 'openai';
latestState.kiroTargetId = 'kiro-rs';
events.push({ type: 'flow-switch-race' });
}`,
});
const result = await api.startAutoRunFromCurrentSettings();
const sendEvent = api.getEvents().find((entry) => entry.type === 'send');
assert.equal(result, true);
assert.equal(sendEvent.message.payload.activeFlowId, 'kiro');
assert.equal(sendEvent.message.payload.targetId, 'kiro-rs');
});
test('startAutoRunFromCurrentSettings blocks when shared flow capability validation fails', async () => {
const bundle = [
extractFunction('normalizePendingAutoRunStartRunCount'),
@@ -210,7 +266,7 @@ const latestState = {
activeFlowId: 'site-a',
panelMode: 'cpa',
signupMethod: 'phone',
contributionMode: false,
accountContributionEnabled: false,
phoneVerificationEnabled: true,
};
const inputAutoSkipFailures = { checked: false };
+8 -1
View File
@@ -5,13 +5,20 @@ const fs = require('node:fs');
test('sidepanel html keeps a single contribution mode button in header', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const matches = html.match(/id="btn-contribution-mode"/g) || [];
const headerEndIndex = html.indexOf('</header>');
const adBarIndex = html.indexOf('id="auto-run-ad-bar"');
const contributionLayerIndex = html.indexOf('id="contribution-update-layer"');
const serviceIndex = html.indexOf('<script src="contribution-content-update-service.js"></script>');
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
assert.equal(matches.length, 1);
assert.match(html, /id="btn-contribution-mode"[^>]*title="进入贡献模式并打开官网页"/);
assert.match(html, />贡献\/使用教程<\/button>/);
assert.match(html, /<\/header>\s*<div id="contribution-update-layer"/);
assert.notEqual(headerEndIndex, -1);
assert.notEqual(adBarIndex, -1);
assert.notEqual(contributionLayerIndex, -1);
assert.ok(headerEndIndex < adBarIndex);
assert.ok(adBarIndex < contributionLayerIndex);
assert.match(html, /id="contribution-update-layer"/);
assert.match(html, /id="contribution-update-hint"/);
assert.match(html, /id="contribution-update-hint-text"/);
@@ -0,0 +1,224 @@
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('sidepanel/contribution-mode.js', 'utf8');
const flowRegistrySource = fs.readFileSync('shared/flow-registry.js', 'utf8');
const contributionRegistrySource = fs.readFileSync('shared/contribution-registry.js', 'utf8');
function createElement() {
return {
hidden: false,
disabled: false,
title: '',
textContent: '',
value: '',
classList: {
hiddenState: false,
toggle(_className, hidden) {
this.hiddenState = Boolean(hidden);
},
},
setAttribute() {},
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
},
};
}
test('contribution mode manager does not project openai-only ui state into kiro flow', () => {
const context = {
window: {},
document: { activeElement: null },
console,
setTimeout,
clearTimeout,
};
vm.runInNewContext(source, context);
const createContributionModeManager = context.window.SidepanelContributionMode.createContributionModeManager;
const rowVpsUrl = createElement();
const dom = {
btnContributionMode: createElement(),
accountContributionPanel: createElement(),
accountContributionText: createElement(),
accountContributionBadge: createElement(),
contributionPrimaryStatusLabel: createElement(),
contributionSecondaryStatusLabel: createElement(),
contributionOauthStatus: createElement(),
contributionCallbackStatus: createElement(),
accountContributionSummary: createElement(),
inputContributionNickname: createElement(),
inputContributionQq: createElement(),
btnStartContribution: createElement(),
btnOpenContributionUpload: createElement(),
btnExitContributionMode: createElement(),
btnOpenAccountRecords: createElement(),
selectPanelMode: createElement(),
rowVpsUrl,
};
const manager = createContributionModeManager({
state: {
getLatestState: () => ({
activeFlowId: 'kiro',
flowId: 'kiro',
accountContributionEnabled: true,
supportsAccountContribution: true,
contributionAdapterId: 'kiro-builder-id',
contributionSource: 'cpa',
}),
},
dom,
helpers: {
updatePanelModeUI() {},
updateAccountRunHistorySettingsUI() {},
updateConfigMenuControls() {},
closeConfigMenu() {},
closeAccountRecordsPanel() {},
getSelectedFlowId() {
return 'kiro';
},
getSelectedTargetId() {
return 'kiro-rs';
},
isModeSwitchBlocked() {
return false;
},
},
runtime: {
sendMessage: async () => ({}),
},
constants: {},
});
manager.render();
assert.equal(dom.accountContributionPanel.hidden, false);
assert.equal(dom.contributionPrimaryStatusLabel.textContent, '账号产物');
assert.equal(dom.contributionSecondaryStatusLabel.textContent, '提交');
assert.equal(dom.contributionOauthStatus.textContent, '等待账号产物');
assert.equal(dom.selectPanelMode.disabled, false);
assert.equal(dom.selectPanelMode.value, '');
assert.equal(dom.btnContributionMode.disabled, false);
assert.equal(dom.btnContributionMode.title, '打开当前 flow 教程;当前已在贡献模式');
assert.equal(dom.btnStartContribution.disabled, false);
assert.equal(dom.btnOpenContributionUpload.hidden, false);
assert.equal(dom.btnOpenContributionUpload.disabled, false);
assert.equal(dom.btnOpenContributionUpload.textContent, '已有认证文件?前往上传');
assert.equal(rowVpsUrl.classList.hiddenState, false);
});
test('combined contribution tutorial button opens current flow page and enables current flow adapter', async () => {
const windowObject = {};
const api = new Function(
'self',
'window',
'setTimeout',
'clearTimeout',
`${flowRegistrySource}; ${contributionRegistrySource}; ${source}; return window.SidepanelContributionMode;`
)(windowObject, windowObject, setTimeout, clearTimeout);
let latestState = {
activeFlowId: 'openai',
flowId: 'openai',
accountContributionEnabled: false,
supportsAccountContribution: true,
contributionAdapterId: '',
panelMode: 'sub2api',
};
const openedUrls = [];
const sentMessages = [];
let persistCount = 0;
const dom = {
btnContributionMode: createElement(),
accountContributionPanel: createElement(),
accountContributionText: createElement(),
accountContributionBadge: createElement(),
contributionPrimaryStatusLabel: createElement(),
contributionSecondaryStatusLabel: createElement(),
contributionOauthStatus: createElement(),
contributionCallbackStatus: createElement(),
accountContributionSummary: createElement(),
inputContributionNickname: createElement(),
inputContributionQq: createElement(),
btnStartContribution: createElement(),
btnOpenContributionUpload: createElement(),
btnExitContributionMode: createElement(),
btnOpenAccountRecords: createElement(),
selectPanelMode: createElement(),
};
const manager = api.createContributionModeManager({
state: {
getLatestState: () => latestState,
},
dom,
helpers: {
applySettingsState(nextState) {
latestState = nextState;
},
updatePanelModeUI() {},
updateAccountRunHistorySettingsUI() {},
updateConfigMenuControls() {},
closeConfigMenu() {},
closeAccountRecordsPanel() {},
getSelectedFlowId() {
return 'kiro';
},
getSelectedTargetId() {
return 'kiro-rs';
},
isModeSwitchBlocked() {
return false;
},
openExternalUrl(url) {
openedUrls.push(url);
},
async persistCurrentSettingsForAction() {
persistCount += 1;
latestState = {
...latestState,
activeFlowId: 'kiro',
flowId: 'kiro',
kiroTargetId: 'kiro-rs',
};
},
showToast() {},
},
runtime: {
sendMessage: async (message) => {
sentMessages.push(message);
return {
state: {
...latestState,
activeFlowId: 'openai',
flowId: 'openai',
accountContributionEnabled: true,
contributionAdapterId: message.payload.adapterId,
},
};
},
},
constants: {
contributionPortalUrl: 'https://flowpilot.qlhazycoder.top',
},
});
manager.bindEvents();
await dom.btnContributionMode.listeners.click();
assert.deepEqual(openedUrls, ['https://flowpilot.qlhazycoder.top/tutorial?flow=kiro&target=kiro-rs']);
assert.equal(sentMessages[0].type, 'SET_ACCOUNT_CONTRIBUTION_MODE');
assert.deepEqual(sentMessages[0].payload, {
enabled: true,
flowId: 'kiro',
adapterId: 'kiro-builder-id',
});
assert.equal(persistCount, 1);
assert.equal(latestState.accountContributionEnabled, true);
assert.equal(latestState.contributionAdapterId, 'kiro-builder-id');
assert.equal(latestState.activeFlowId, 'kiro');
assert.equal(latestState.flowId, 'kiro');
assert.equal(latestState.kiroTargetId, 'kiro-rs');
});
+27 -31
View File
@@ -193,8 +193,11 @@ test('collectSettingsPayload omits custom password and local sync settings in co
const bundle = extractFunction('collectSettingsPayload');
const api = new Function('normalizeIcloudTargetMailboxType', 'normalizeIcloudForwardMailProvider', `
let latestState = { contributionMode: true };
let latestState = { accountContributionEnabled: true };
const window = {};
const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth';
const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';
const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH;
let cloudflareDomainEditMode = false;
let cloudflareTempEmailDomainEditMode = false;
const selectCfDomain = { value: 'example.com' };
@@ -297,6 +300,7 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
function normalizePlusAccountAccessStrategy(value = '') { return String(value || '').trim().toLowerCase() === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; }
function normalizePhoneSmsProvider(value = '') { return String(value || '').trim().toLowerCase() === '5sim' ? '5sim' : 'hero-sms'; }
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
function normalizeFiveSimCountryId(value, fallback = DEFAULT_FIVE_SIM_COUNTRY_ID) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
@@ -333,7 +337,7 @@ return {
assert.equal(contributionPayload.phoneVerificationEnabled, true);
assert.equal(contributionPayload.cloudflareTempEmailUseRandomSubdomain, true);
api.setLatestState({ contributionMode: false });
api.setLatestState({ accountContributionEnabled: false });
const normalPayload = api.collectSettingsPayload();
assert.equal(normalPayload.panelMode, 'cpa');
assert.equal(normalPayload.customPassword, 'Secret123!');
@@ -366,7 +370,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
assert.equal(typeof api?.createContributionModeManager, 'function');
let latestState = {
contributionMode: false,
accountContributionEnabled: false,
panelMode: 'sub2api',
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
@@ -397,13 +401,13 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
btnOpenAccountRecords: createElement(),
btnOpenContributionUpload: createElement(),
btnStartContribution: createElement(),
contributionModeBadge: createElement(),
accountContributionBadge: createElement(),
inputContributionNickname: createElement({ value: '贡献者昵称' }),
inputContributionQq: createElement({ value: '123456' }),
contributionCallbackStatus: createElement(),
contributionModePanel: createElement({ hidden: true }),
contributionModeSummary: createElement(),
contributionModeText: createElement(),
accountContributionPanel: createElement({ hidden: true }),
accountContributionSummary: createElement(),
accountContributionText: createElement(),
contributionOauthStatus: createElement(),
rowAccountRunHistoryHelperBaseUrl: createElement(),
rowAccountRunHistoryTextEnabled: createElement(),
@@ -488,11 +492,11 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
runtime: {
sendMessage: async (message) => {
sentMessages.push(message);
if (message.type === 'SET_CONTRIBUTION_MODE') {
if (message.type === 'SET_ACCOUNT_CONTRIBUTION_MODE') {
return {
state: message.payload.enabled
? {
contributionMode: true,
accountContributionEnabled: true,
panelMode: 'sub2api',
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
@@ -508,7 +512,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
email: latestState.email,
}
: {
contributionMode: false,
accountContributionEnabled: false,
panelMode: 'cpa',
contributionSource: 'cpa',
contributionTargetGroupName: '',
@@ -526,7 +530,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
},
};
}
if (message.type === 'POLL_CONTRIBUTION_STATUS') {
if (message.type === 'POLL_FLOW_CONTRIBUTION_STATUS') {
return {
state: {
...latestState,
@@ -537,14 +541,6 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
},
};
}
if (message.type === 'SET_CONTRIBUTION_PROFILE') {
latestState = {
...latestState,
contributionNickname: message.payload.nickname,
contributionQq: message.payload.qq,
};
return { state: latestState };
}
return {};
},
},
@@ -556,21 +552,21 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
});
manager.render();
assert.equal(dom.contributionModePanel.hidden, true);
assert.equal(dom.accountContributionPanel.hidden, true);
assert.equal(dom.btnContributionMode.disabled, false);
assert.equal(dom.contributionModeBadge.textContent, '');
assert.equal(dom.accountContributionBadge.textContent, '');
manager.bindEvents();
await dom.btnContributionMode.listeners.click();
assert.equal(dom.contributionModePanel.hidden, false);
assert.equal(dom.accountContributionPanel.hidden, false);
assert.equal(dom.selectPanelMode.value, 'sub2api');
assert.equal(dom.selectPanelMode.disabled, true);
assert.equal(dom.contributionModeBadge.textContent, 'SUB2API');
assert.equal(dom.accountContributionBadge.textContent, 'SUB2API');
assert.equal(dom.btnOpenAccountRecords.disabled, true);
assert.equal(dom.contributionOauthStatus.textContent, '\u672a\u751f\u6210\u767b\u5f55\u5730\u5740');
assert.equal(dom.contributionCallbackStatus.textContent, '\u7b49\u5f85\u56de\u8c03');
assert.equal(dom.contributionModeSummary.textContent.length > 0, true);
assert.equal(dom.accountContributionSummary.textContent.length > 0, true);
assert.equal(dom.btnContributionMode.classList.contains('is-active'), true);
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), true);
assert.equal(dom.rowCodex2ApiUrl.classList.contains('is-contribution-hidden'), true);
@@ -591,28 +587,28 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
assert.equal(appliedState.contributionSessionId, '');
assert.equal(latestState.contributionSessionId, 'session-002');
assert.equal(latestState.contributionStatus, 'started');
const contributionProfileMessage = sentMessages.find((message) => message.type === 'SET_CONTRIBUTION_PROFILE');
assert.deepStrictEqual(contributionProfileMessage?.payload, { nickname: '贡献者昵称', qq: '123456' });
assert.equal(latestState.contributionNickname, '贡献者昵称');
assert.equal(latestState.contributionQq, '123456');
assert.equal(timers.length > 0, true);
await manager.pollOnce({ reason: 'test_poll' });
assert.equal(statusState.contributionStatus, 'processing');
assert.equal(dom.contributionOauthStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
assert.equal(dom.contributionCallbackStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85\u670d\u52a1\u7aef\u786e\u8ba4');
assert.equal(dom.accountContributionSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85\u670d\u52a1\u7aef\u786e\u8ba4');
dom.btnOpenContributionUpload.listeners.click();
assert.deepStrictEqual(openedUrls, ['https://flowpilot.qlhazycoder.top', 'https://flowpilot.qlhazycoder.top/upload']);
await dom.btnExitContributionMode.listeners.click();
manager.render();
assert.equal(dom.contributionModePanel.hidden, true);
assert.equal(dom.accountContributionPanel.hidden, true);
assert.equal(dom.btnContributionMode.classList.contains('is-active'), false);
assert.equal(dom.selectPanelMode.disabled, false);
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), false);
assert.deepStrictEqual(
sentMessages.map((message) => message.type),
['SET_CONTRIBUTION_MODE', 'SET_CONTRIBUTION_PROFILE', 'POLL_CONTRIBUTION_STATUS', 'SET_CONTRIBUTION_MODE']
['SET_ACCOUNT_CONTRIBUTION_MODE', 'POLL_FLOW_CONTRIBUTION_STATUS', 'SET_ACCOUNT_CONTRIBUTION_MODE']
);
assert.deepStrictEqual(
toasts.map((item) => item.message),
@@ -621,7 +617,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
blocked = true;
latestState = {
contributionMode: true,
accountContributionEnabled: true,
panelMode: 'sub2api',
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
@@ -636,7 +632,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
};
manager.render();
assert.equal(dom.selectPanelMode.value, 'sub2api');
assert.equal(dom.contributionModeBadge.textContent, 'SUB2API');
assert.equal(dom.accountContributionBadge.textContent, 'SUB2API');
assert.equal(dom.btnExitContributionMode.disabled, true);
manager.stopPolling();
});
@@ -0,0 +1,249 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
const sidepanelHtml = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
function extractFunction(source, name) {
const asyncStart = source.indexOf(`async function ${name}`);
const normalStart = source.indexOf(`function ${name}`);
const start = asyncStart !== -1
? asyncStart
: normalStart;
if (start === -1) {
throw new Error(`Function ${name} not found`);
}
const signatureEnd = source.indexOf(')', start);
const bodyStart = source.indexOf('{', signatureEnd);
let depth = 0;
let end = bodyStart;
for (; end < source.length; end += 1) {
const char = source[end];
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('sidepanel html exposes flow selector and kiro source fields', () => {
[
'id="select-flow"',
'id="label-source-selector"',
'id="row-step6-cookie-settings"',
'id="row-kiro-rs-url"',
'id="btn-open-kiro-rs-github"',
'id="row-kiro-rs-key"',
'id="btn-test-kiro-rs"',
'id="row-kiro-rs-test-status"',
'id="row-kiro-web-status"',
'id="row-kiro-login-url"',
'id="row-kiro-upload-status"',
].forEach((snippet) => {
assert.match(sidepanelHtml, new RegExp(snippet.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});
});
test('sidepanel Kiro GitHub button opens the configured fork', () => {
assert.match(sidepanelSource, /openExternalUrl\('https:\/\/github\.com\/QLHazyCoder\/kiro\.rs'\)/);
assert.doesNotMatch(sidepanelSource, /github\.com\/hank9999\/kiro\.rs/);
});
test('sidepanel step definitions rerender when active flow changes even if plus/signup settings stay the same', () => {
const bundle = [
extractFunction(sidepanelSource, 'normalizeSignupMethod'),
extractFunction(sidepanelSource, 'normalizePlusPaymentMethod'),
extractFunction(sidepanelSource, 'getStepDefinitionsForMode'),
extractFunction(sidepanelSource, 'rebuildStepDefinitionState'),
extractFunction(sidepanelSource, 'syncStepDefinitionsForMode'),
].join('\n');
const api = new Function(`
const calls = [];
const window = {
MultiPageStepDefinitions: {
getSteps(options) {
calls.push({ type: 'getSteps', options });
return [{ id: options.activeFlowId === 'kiro' ? 88 : 6, order: 1, key: options.activeFlowId }];
},
},
};
let latestState = { activeFlowId: 'openai' };
let currentPlusModeEnabled = false;
let currentPlusPaymentMethod = 'paypal';
let currentPlusAccountAccessStrategy = 'oauth';
let currentSignupMethod = 'email';
let currentPhoneSignupReloginAfterBindEmailEnabled = false;
let currentStepDefinitionFlowId = 'openai';
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const DEFAULT_SIGNUP_METHOD = 'email';
const DEFAULT_PLUS_PAYMENT_METHOD = 'paypal';
const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = 'oauth';
let stepDefinitions = [{ id: 6, key: 'openai' }];
let STEP_IDS = [6];
let STEP_DEFAULT_STATUSES = { 6: 'pending' };
let SKIPPABLE_STEPS = new Set([6]);
function renderStepsList() {
calls.push({ type: 'render', stepIds: [...STEP_IDS] });
}
function normalizePlusAccountAccessStrategy(value = '') {
return String(value || DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY).trim().toLowerCase() || DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY;
}
${bundle}
return {
calls,
syncStepDefinitionsForMode,
getStepIds: () => [...STEP_IDS],
getCurrentFlowId: () => currentStepDefinitionFlowId,
};
`)();
api.syncStepDefinitionsForMode(false, {
activeFlowId: 'kiro',
plusPaymentMethod: 'paypal',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
});
assert.equal(api.getCurrentFlowId(), 'kiro');
assert.deepEqual(api.getStepIds(), [88]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: {
activeFlowId: 'kiro',
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
plusAccountAccessStrategy: 'oauth',
signupMethod: 'email',
phoneSignupReloginAfterBindEmailEnabled: false,
},
});
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [88] });
});
test('syncLatestState keeps activeFlowId and flowId in sync when only one side changes', () => {
const bundle = [
extractFunction(sidepanelSource, 'syncLatestState'),
].join('\n');
const api = new Function(`
let latestState = {
activeFlowId: 'openai',
flowId: 'openai',
nodeStatuses: { 'open-chatgpt': 'completed' },
};
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const NODE_DEFAULT_STATUSES = { 'open-chatgpt': 'pending' };
const calls = [];
function normalizeFlowId(value = '', fallback = DEFAULT_ACTIVE_FLOW_ID) {
return String(value || fallback || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase() || DEFAULT_ACTIVE_FLOW_ID;
}
function getStoredNodeStatuses(state = {}) {
return { ...NODE_DEFAULT_STATUSES, ...(state?.nodeStatuses || {}) };
}
function renderAccountRecords(state) {
calls.push({ ...state });
}
${bundle}
return {
syncLatestState,
getLatestState() {
return latestState;
},
getCalls() {
return calls;
},
};
`)();
api.syncLatestState({ flowId: 'kiro' });
assert.deepStrictEqual(api.getLatestState(), {
activeFlowId: 'kiro',
flowId: 'kiro',
nodeStatuses: { 'open-chatgpt': 'completed' },
});
assert.equal(api.getCalls()[0].activeFlowId, 'kiro');
assert.equal(api.getCalls()[0].flowId, 'kiro');
});
test('updatePanelModeUI reapplies dynamic Plus and phone visibility after flow group visibility', () => {
const bundle = [
extractFunction(sidepanelSource, 'updatePanelModeUI'),
].join('\n');
const api = new Function(`
const calls = [];
let latestState = {
activeFlowId: 'openai',
flowId: 'openai',
panelMode: 'cpa',
};
const DEFAULT_ACTIVE_FLOW_ID = 'openai';
const selectFlow = { value: '' };
const selectPanelMode = { value: '' };
function normalizeFlowId(value = '', fallback = DEFAULT_ACTIVE_FLOW_ID) {
return String(value || fallback || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase() || DEFAULT_ACTIVE_FLOW_ID;
}
function normalizePanelMode(value = '', fallback = 'cpa') {
return String(value || fallback || 'cpa').trim().toLowerCase() || 'cpa';
}
function getSelectedFlowId() {
return latestState.activeFlowId;
}
function getSelectedTargetId() {
return 'cpa';
}
function renderFlowSelectorOptions(flowId) {
calls.push({ type: 'render-flow', flowId });
}
function renderTargetSelectorOptions(flowId, targetId) {
calls.push({ type: 'render-target', flowId, targetId });
}
function applyFlowSettingsGroupVisibility(visibleGroupIds) {
calls.push({ type: 'groups', visibleGroupIds: [...visibleGroupIds] });
}
function updatePlusModeUI() {
calls.push({ type: 'plus' });
}
function updatePhoneVerificationSettingsUI() {
calls.push({ type: 'phone' });
}
function resolveCurrentSidepanelCapabilities() {
return {
visibleGroupIds: ['service-account', 'openai-plus', 'openai-phone'],
effectivePanelMode: 'cpa',
panelMode: 'cpa',
effectiveTargetId: 'cpa',
};
}
const document = {
querySelector() {
return null;
},
};
${bundle}
return {
calls,
updatePanelModeUI,
selectFlow,
selectPanelMode,
};
`)();
api.updatePanelModeUI();
assert.deepEqual(
api.calls.map((entry) => entry.type),
['render-flow', 'render-target', 'groups', 'plus', 'phone']
);
assert.equal(api.selectFlow.value, 'openai');
assert.equal(api.selectPanelMode.value, 'cpa');
});
+9 -1
View File
@@ -84,8 +84,11 @@ test('collectSettingsPayload persists icloud target mailbox settings', () => {
const bundle = extractFunction('collectSettingsPayload');
const api = new Function(`
let latestState = { contributionMode: false };
let latestState = { accountContributionEnabled: false };
const window = {};
const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth';
const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';
const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH;
let cloudflareDomainEditMode = false;
let cloudflareTempEmailDomainEditMode = false;
const selectCfDomain = { value: '' };
@@ -220,6 +223,7 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
function normalizePlusAccountAccessStrategy(value = '') { return String(value || '').trim().toLowerCase() === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; }
function normalizePhoneSmsProvider(value = '') { return String(value || '').trim().toLowerCase() === '5sim' ? '5sim' : 'hero-sms'; }
function setPhoneSmsProviderSelectValue(provider) {
const normalizedProvider = normalizePhoneSmsProvider(provider);
@@ -363,6 +367,9 @@ test('applySettingsState restores icloud forward mailbox settings before UI refr
const api = new Function('calls', `
let latestState = {};
const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth';
const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';
const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH;
const inputEmail = { value: '' };
const inputVpsUrl = { value: '' };
const inputVpsPassword = { value: '' };
@@ -460,6 +467,7 @@ function normalizePanelMode(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'sub2api' || normalized === 'codex2api' ? normalized : 'cpa';
}
function normalizePlusAccountAccessStrategy(value = '') { return String(value || '').trim().toLowerCase() === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; }
function isCustomMailProvider() { return false; }
function setMail2925Mode() {}
function normalizeIcloudFetchMode(value) { return String(value || '') === 'always_new' ? 'always_new' : 'reuse_existing'; }
+5 -1
View File
@@ -154,11 +154,14 @@ test('collectSettingsPayload persists currentMail2925AccountId for 2925 account
const api = new Function('normalizeIcloudTargetMailboxType', 'normalizeIcloudForwardMailProvider', `
let latestState = {
contributionMode: false,
accountContributionEnabled: false,
mail2925UseAccountPool: true,
currentMail2925AccountId: 'acc-2',
};
const window = {};
const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth';
const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';
const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH;
let cloudflareDomainEditMode = false;
let cloudflareTempEmailDomainEditMode = false;
const selectCfDomain = { value: 'example.com' };
@@ -258,6 +261,7 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
function normalizePlusAccountAccessStrategy(value = '') { return String(value || '').trim().toLowerCase() === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; }
function normalizePhoneSmsProvider(value = '') { return String(value || '').trim().toLowerCase() === '5sim' ? '5sim' : 'hero-sms'; }
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
function normalizeFiveSimCountryId(value, fallback = DEFAULT_FIVE_SIM_COUNTRY_ID) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
+5 -5
View File
@@ -72,7 +72,7 @@ const localStorage = {
},
};
const btnContributionMode = { disabled: false };
let latestState = { contributionMode: false };
let latestState = { accountContributionEnabled: false };
${bundle}
return {
shouldPromptNewUserGuide,
@@ -82,8 +82,8 @@ return {
setButtonDisabled(value) {
btnContributionMode.disabled = Boolean(value);
},
setContributionMode(value) {
latestState = { contributionMode: Boolean(value) };
setAccountContributionMode(value) {
latestState = { accountContributionEnabled: Boolean(value) };
},
};
`)();
@@ -98,7 +98,7 @@ return {
assert.equal(api.shouldPromptNewUserGuide(), false);
api.setButtonDisabled(false);
api.setContributionMode(true);
api.setAccountContributionMode(true);
assert.equal(api.shouldPromptNewUserGuide(), false);
});
@@ -129,7 +129,7 @@ const localStorage = {
},
};
const btnContributionMode = { disabled: false };
const latestState = { contributionMode: false };
const latestState = { accountContributionEnabled: false };
const contributionContentService = { portalUrl: 'https://flowpilot.qlhazycoder.top' };
const openedUrls = [];
let modalOptions = null;
+29 -78
View File
@@ -38,95 +38,46 @@ function extractFunction(name) {
throw new Error(`unterminated ${name}`);
}
test('sidepanel exposes one operation delay switch in the existing settings surface', () => {
assert.match(html, /id="row-operation-delay-settings"/);
assert.match(html, /id="input-operation-delay-enabled"/);
assert.match(html, /操作间延迟/);
assert.match(html, /2\s*秒/);
assert.match(html, /id="row-operation-delay-settings"[\s\S]{0,900}输入[\s\S]{0,900}选择[\s\S]{0,900}点击[\s\S]{0,900}提交[\s\S]{0,900}继续[\s\S]{0,900}授权/);
test('sidepanel no longer exposes operation delay switch and places step execution range below oauth timeout', () => {
assert.doesNotMatch(html, /id="row-operation-delay-settings"/);
assert.doesNotMatch(html, /id="input-operation-delay-enabled"/);
const step6CookieIndex = html.indexOf('id="row-step6-cookie-settings"');
const autoDelayIndex = html.indexOf('id="row-auto-delay-settings"');
const oauthTimeoutIndex = html.indexOf('id="row-oauth-flow-timeout"');
const stepRangeIndex = html.indexOf('id="row-step-execution-range"');
const oauthDisplayIndex = html.indexOf('id="row-oauth-display"');
assert.notEqual(step6CookieIndex, -1);
assert.notEqual(autoDelayIndex, -1);
assert.notEqual(oauthTimeoutIndex, -1);
assert.notEqual(stepRangeIndex, -1);
assert.notEqual(oauthDisplayIndex, -1);
assert.ok(autoDelayIndex > step6CookieIndex, 'startup delay row should render below the openai step6 cookie row');
assert.ok(stepRangeIndex > autoDelayIndex, 'step execution range should still remain below the startup delay row');
assert.ok(stepRangeIndex > oauthTimeoutIndex, 'step execution range should render below oauth timeout');
assert.ok(stepRangeIndex < oauthDisplayIndex, 'step execution range should stay above oauth runtime display');
});
test('sidepanel owns operation delay toggle feedback exactly once', () => {
assert.match(source, /inputOperationDelayEnabled/);
assert.match(source, /operationDelayEnabled/);
assert.match(source, /appendLog\(\{[\s\S]*操作间延迟/);
assert.doesNotMatch(source, /operationDelayEnabled[\s\S]{0,240}addLog\(/);
});
test('sidepanel operation delay restore and save failure contracts are explicit', () => {
assert.match(source, /normalizeOperationDelayEnabled/);
assert.match(source, /lastConfirmedOperationDelayEnabled/);
assert.match(source, /操作间延迟设置读取失败/);
assert.match(source, /操作间延迟设置保存失败/);
assert.match(source, /inputOperationDelayEnabled\.checked\s*=\s*lastConfirmedOperationDelayEnabled/);
assert.match(source, /typeof\s+applyOperationDelayState\s*===\s*['"]function['"]/);
});
test('sidepanel syncs operation delay DATA_UPDATED without becoming the success-log owner', () => {
assert.match(source, /message\.payload\.operationDelayEnabled\s*!==\s*undefined[\s\S]{0,240}applyOperationDelayState\(message\.payload\)/);
});
function loadOperationDelaySidepanelHarness() {
const runtimeMessages = [];
const logs = [];
const inputOperationDelayEnabled = { checked: true, disabled: false };
const chrome = {
runtime: {
sendMessage: async (message) => {
runtimeMessages.push(message);
return { ok: true, state: { operationDelayEnabled: message.payload.operationDelayEnabled } };
},
},
};
const harness = new Function('chrome', 'appendLog', 'inputOperationDelayEnabled', 'initialLatestState', `
let latestState = initialLatestState;
let lastConfirmedOperationDelayEnabled = true;
test('sidepanel operation delay state is always normalized back to enabled', () => {
const harness = new Function(`
let latestState = { operationDelayEnabled: false };
function syncLatestState(nextState) {
latestState = { ...(latestState || {}), ...(nextState || {}) };
}
${extractFunction('normalizeOperationDelayEnabled')}
${extractFunction('appendOperationDelayLog')}
${extractFunction('applyOperationDelayState')}
${extractFunction('persistOperationDelayToggle')}
return {
normalizeOperationDelayEnabled,
applyOperationDelayState,
persistOperationDelayToggle,
getLatestState: () => latestState,
getLastConfirmedOperationDelayEnabled: () => lastConfirmedOperationDelayEnabled,
setLastConfirmedOperationDelayEnabled: (value) => { lastConfirmedOperationDelayEnabled = value; },
};
`)(chrome, (entry) => logs.push(entry), inputOperationDelayEnabled, { operationDelayEnabled: true });
`)();
return { chrome, harness, inputOperationDelayEnabled, logs, runtimeMessages };
}
assert.equal(harness.normalizeOperationDelayEnabled(undefined), true);
assert.equal(harness.normalizeOperationDelayEnabled(false), true);
assert.equal(harness.normalizeOperationDelayEnabled(true), true);
test('operation delay switch logs once, defaults restore failures to enabled, and rolls back save failures', async () => {
const { chrome, harness: helpers, inputOperationDelayEnabled: input, logs, runtimeMessages: sentMessages } = loadOperationDelaySidepanelHarness();
helpers.applyOperationDelayState({ operationDelayEnabled: false });
assert.equal(input.checked, false);
assert.equal(helpers.getLatestState().operationDelayEnabled, false);
helpers.applyOperationDelayState({ operationDelayEnabled: undefined }, { restoreFailed: true });
assert.equal(input.checked, true);
assert.equal(helpers.getLatestState().operationDelayEnabled, true);
assert.equal(logs.filter((entry) => entry.level === 'warn').length, 1);
logs.length = 0;
input.checked = false;
await helpers.persistOperationDelayToggle();
assert.equal(sentMessages.at(-1).payload.operationDelayEnabled, false);
assert.equal(logs.length, 1);
assert.match(logs[0].message, /关闭|off/i);
sentMessages.length = 0;
chrome.runtime.sendMessage = async () => { throw new Error('network down'); };
logs.length = 0;
input.checked = true;
helpers.setLastConfirmedOperationDelayEnabled(false);
await assert.rejects(() => helpers.persistOperationDelayToggle(), /network down/);
assert.equal(input.checked, false);
assert.equal(logs.filter((entry) => entry.level === 'error').length, 1);
harness.applyOperationDelayState({ operationDelayEnabled: false });
assert.equal(harness.getLatestState().operationDelayEnabled, true);
});
+6
View File
@@ -20,8 +20,14 @@ test('sidepanel loads reusable form dialog and paypal manager before sidepanel b
test('sidepanel html contains paypal select and GoPay controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const plusAccountAccessStrategyIndex = html.indexOf('id="row-plus-account-access-strategy"');
const plusPaymentMethodIndex = html.indexOf('id="row-plus-payment-method"');
assert.match(html, /id="row-plus-payment-method"/);
assert.match(html, /id="row-plus-account-access-strategy"/);
assert.notEqual(plusAccountAccessStrategyIndex, -1);
assert.notEqual(plusPaymentMethodIndex, -1);
assert.ok(plusAccountAccessStrategyIndex < plusPaymentMethodIndex);
assert.match(html, /id="select-plus-payment-method"/);
assert.match(html, /id="row-paypal-account"/);
assert.match(html, /id="select-paypal-account"/);
@@ -148,6 +148,21 @@ test('sidepanel html exposes phone verification toggle and multi-provider SMS ro
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
});
test('sidepanel loads SMS country lists silently during startup fallback', () => {
const heroLoader = extractFunction('loadHeroSmsCountries');
const fiveSimLoader = extractFunction('loadFiveSimCountries');
assert.match(heroLoader, /async function loadHeroSmsCountries\(options = \{\}\)/);
assert.match(fiveSimLoader, /async function loadFiveSimCountries\(options = \{\}\)/);
assert.match(heroLoader, /const preferFallbackOnly = Boolean\(options\?\.preferFallbackOnly\)/);
assert.match(fiveSimLoader, /const preferFallbackOnly = Boolean\(options\?\.preferFallbackOnly\)/);
assert.doesNotMatch(heroLoader, /console\.(?:warn|error)\('加载 (?:5sim|HeroSMS) 国家列表失败:'/);
assert.doesNotMatch(fiveSimLoader, /console\.(?:warn|error)\('加载 5sim 国家列表失败:'/);
assert.match(sidepanelSource, /loadHeroSmsCountries\(\{ silent: true, preferFallbackOnly: true \}\)/);
assert.match(sidepanelSource, /loadFiveSimCountries\(\{ silent: true, preferFallbackOnly: true \}\)/);
assert.doesNotMatch(sidepanelSource, /console\.error\('加载 (?:HeroSMS|5sim|NexSMS) 国家列表失败:'/);
});
test('sidepanel source wires free reusable phone save and clear actions to runtime messages', () => {
assert.match(sidepanelSource, /const inputFreePhoneReuseEnabled = document\.getElementById\('input-free-phone-reuse-enabled'\);/);
assert.match(sidepanelSource, /const inputFreePhoneReuseAutoEnabled = document\.getElementById\('input-free-phone-reuse-auto-enabled'\);/);
@@ -233,7 +248,7 @@ const SIGNUP_METHOD_PHONE = 'phone';
const SIGNUP_METHOD_EMAIL = 'email';
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
const CPA_PHONE_SIGNUP_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-cpa-phone-signup-prompt-dismissed';
const CPA_PHONE_SIGNUP_WARNING_MESSAGE = 'CPA 未适配手机号注册模式,认证成功后无法使用。请使用 SUB2API,或者认证成功后重新登录一遍进行解决。';
const CPA_PHONE_SIGNUP_WARNING_MESSAGE = '请确保打开手机接码设置中的“绑定后重登”开关,不然可能无法使用(有些版本无需开启)';
const storage = new Map();
const localStorage = {
getItem(key) {
@@ -284,7 +299,7 @@ return {
const firstResult = await api.confirmCpaPhoneSignupIfNeeded({ signupMethod: 'phone', panelMode: 'cpa' });
assert.equal(firstResult, true);
assert.equal(api.getCapturedOptions().title, 'CPA 手机号注册提醒');
assert.equal(api.getCapturedOptions().message, 'CPA 未适配手机号注册模式,认证成功后无法使用。请使用 SUB2API,或者认证成功后重新登录一遍进行解决。');
assert.equal(api.getCapturedOptions().message, '请确保打开手机接码设置中的“绑定后重登”开关,不然可能无法使用(有些版本无需开启)');
assert.equal(api.getCapturedOptions().confirmLabel, '继续');
assert.equal(api.getCapturedOptions().optionLabel, '不再提醒');
assert.equal(api.getDismissed(), null);
@@ -322,7 +337,7 @@ const window = {
};
let latestState = {
activeFlowId: 'site-a',
contributionMode: false,
accountContributionEnabled: false,
panelMode: 'cpa',
};
const inputPhoneVerificationEnabled = { checked: true };
@@ -858,8 +873,11 @@ return {
test('collectSettingsPayload keeps local helper sync enabled while persisting sms toggle state', () => {
const api = new Function('normalizeIcloudTargetMailboxType', 'normalizeIcloudForwardMailProvider', `
const window = {};
const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth';
const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';
const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH;
let latestState = {
contributionMode: false,
accountContributionEnabled: false,
mail2925UseAccountPool: false,
currentMail2925AccountId: '',
fiveSimCountryOrder: ['thailand', 'england'],
@@ -1010,6 +1028,7 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
function normalizePlusAccountAccessStrategy(value = '') { return String(value || '').trim().toLowerCase() === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; }
${extractFunction('normalizePhoneSmsProvider')}
${extractFunction('normalizePhoneSmsProviderValue')}
${extractFunction('normalizeFiveSimCountryCode')}
+70 -2
View File
@@ -65,6 +65,7 @@ test('sidepanel step definitions keep the selected Plus payment method', () => {
const bundle = [
extractFunction('normalizeSignupMethod'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getStepDefinitionsForMode'),
extractFunction('rebuildStepDefinitionState'),
extractFunction('syncStepDefinitionsForMode'),
@@ -82,6 +83,11 @@ const window = {
};
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';
let currentPhoneSignupReloginAfterBindEmailEnabled = false;
const DEFAULT_SIGNUP_METHOD = 'email';
@@ -107,7 +113,7 @@ return {
assert.deepEqual(api.getStepIds(), [7]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'email', phoneSignupReloginAfterBindEmailEnabled: false },
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', plusAccountAccessStrategy: 'oauth', signupMethod: 'email', phoneSignupReloginAfterBindEmailEnabled: false },
});
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [7] });
});
@@ -141,7 +147,9 @@ test('sidepanel applies restored signup method when rebuilding shared step defin
test('sidepanel Plus UI hides PayPal account selector while GoPay is selected', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('getRequestedPlusAccountAccessStrategy'),
extractFunction('normalizeGpcHelperPhoneModeValue'),
extractFunction('getGpcHelperAutoModeEnabled'),
extractFunction('normalizeGpcAutoModePermissionValue'),
@@ -156,10 +164,15 @@ test('sidepanel Plus UI hides PayPal account selector while GoPay is selected',
const api = new Function(`
let latestState = { plusPaymentMethod: 'gopay' };
let currentPlusPaymentMethod = 'paypal';
let currentPlusAccountAccessStrategy = 'oauth';
const inputPlusModeEnabled = { checked: true };
const selectPlusPaymentMethod = { value: 'gopay', 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 rowPayPalAccount = { style: { display: '' } };
${bundle}
return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount };
@@ -178,7 +191,9 @@ return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount };
test('sidepanel Plus UI can hide Plus controls when the shared flow capability registry disables them', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('getRequestedPlusAccountAccessStrategy'),
extractFunction('normalizeGpcHelperPhoneModeValue'),
extractFunction('getGpcHelperAutoModeEnabled'),
extractFunction('normalizeGpcAutoModePermissionValue'),
@@ -206,6 +221,7 @@ const window = {
},
};
let latestState = { plusPaymentMethod: 'paypal' };
let currentPlusAccountAccessStrategy = 'oauth';
const inputPlusModeEnabled = { checked: true };
const rowPlusMode = { style: { display: '' } };
const selectPlusPaymentMethod = { value: 'paypal', style: { display: '' } };
@@ -213,6 +229,10 @@ const rowPlusPaymentMethod = { style: { display: '' } };
const rowPayPalAccount = { style: { display: '' } };
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 {
rowPlusMode,
@@ -235,6 +255,7 @@ test('sidepanel step definitions keep GPC helper mode distinct', () => {
const bundle = [
extractFunction('normalizeSignupMethod'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getStepDefinitionsForMode'),
extractFunction('rebuildStepDefinitionState'),
extractFunction('syncStepDefinitionsForMode'),
@@ -252,6 +273,11 @@ const window = {
};
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';
let currentPhoneSignupReloginAfterBindEmailEnabled = false;
const DEFAULT_SIGNUP_METHOD = 'email';
@@ -277,14 +303,16 @@ return {
assert.deepEqual(api.getStepIds(), [13]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', signupMethod: 'email', phoneSignupReloginAfterBindEmailEnabled: false },
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', plusAccountAccessStrategy: 'oauth', signupMethod: 'email', phoneSignupReloginAfterBindEmailEnabled: false },
});
});
test('sidepanel Plus UI shows GPC fields and purchase button only for GPC', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('getRequestedPlusAccountAccessStrategy'),
extractFunction('normalizeGpcHelperPhoneModeValue'),
extractFunction('getGpcHelperAutoModeEnabled'),
extractFunction('normalizeGpcAutoModePermissionValue'),
@@ -299,10 +327,15 @@ test('sidepanel Plus UI shows GPC fields and purchase button only for GPC', () =
const api = new Function(`
let latestState = { plusPaymentMethod: 'gpc-helper', gopayHelperAutoModeEnabled: true };
let currentPlusPaymentMethod = 'paypal';
let currentPlusAccountAccessStrategy = 'oauth';
const inputPlusModeEnabled = { checked: true };
const selectPlusPaymentMethod = { value: 'gpc-helper', 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 btnGpcCardKeyPurchase = { style: { display: 'none' } };
const rowPayPalAccount = { style: { display: '' } };
@@ -380,7 +413,9 @@ return {
test('sidepanel keeps selected GPC auto mode when API Key has no auto permission', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('getRequestedPlusAccountAccessStrategy'),
extractFunction('normalizeGpcHelperPhoneModeValue'),
extractFunction('getGpcHelperAutoModeEnabled'),
extractFunction('normalizeGpcAutoModePermissionValue'),
@@ -395,10 +430,15 @@ test('sidepanel keeps selected GPC auto mode when API Key has no auto permission
const api = new Function(`
let latestState = { plusPaymentMethod: 'gpc-helper', gopayHelperPhoneMode: 'auto', gopayHelperAutoModeEnabled: false, gopayHelperBalancePayload: { auto_mode_enabled: false } };
let currentPlusPaymentMethod = 'gpc-helper';
let currentPlusAccountAccessStrategy = 'oauth';
const inputPlusModeEnabled = { checked: true };
const selectPlusPaymentMethod = { value: 'gpc-helper', 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 btnGpcCardKeyPurchase = { style: { display: 'none' } };
const rowPayPalAccount = { style: { display: '' } };
@@ -432,7 +472,9 @@ return { updatePlusModeUI, selectGpcHelperPhoneMode, plusPaymentMethodCaption, r
test('sidepanel keeps selected GPC auto mode when persisted permission survives stop refresh', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('getRequestedPlusAccountAccessStrategy'),
extractFunction('normalizeGpcHelperPhoneModeValue'),
extractFunction('getGpcHelperAutoModeEnabled'),
extractFunction('normalizeGpcAutoModePermissionValue'),
@@ -452,10 +494,15 @@ let latestState = {
gopayHelperBalancePayload: { auto_mode_enabled: true },
};
let currentPlusPaymentMethod = 'gpc-helper';
let currentPlusAccountAccessStrategy = 'oauth';
const inputPlusModeEnabled = { checked: true };
const selectPlusPaymentMethod = { value: 'gpc-helper', 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 rowPlusPaymentMethod = { style: { display: 'none' } };
@@ -513,7 +560,9 @@ return {
test('sidepanel keeps selected GPC auto mode before permission has been queried', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePlusAccountAccessStrategy'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('getRequestedPlusAccountAccessStrategy'),
extractFunction('normalizeGpcHelperPhoneModeValue'),
extractFunction('getGpcHelperAutoModeEnabled'),
extractFunction('normalizeGpcAutoModePermissionValue'),
@@ -528,10 +577,15 @@ test('sidepanel keeps selected GPC auto mode before permission has been queried'
const api = new Function(`
let latestState = { plusPaymentMethod: 'gpc-helper', gopayHelperPhoneMode: 'auto', gopayHelperAutoModeEnabled: false, gopayHelperBalancePayload: null };
let currentPlusPaymentMethod = 'gpc-helper';
let currentPlusAccountAccessStrategy = 'oauth';
const inputPlusModeEnabled = { checked: true };
const selectPlusPaymentMethod = { value: 'gpc-helper', 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 rowPlusPaymentMethod = { style: { display: 'none' } };
@@ -699,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,
@@ -753,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');
@@ -769,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 });
+144
View File
@@ -0,0 +1,144 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
assert.notEqual(start, -1, `missing ${name}`);
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
let depth = 0;
for (let i = braceStart; i < source.length; i += 1) {
const ch = source[i];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(start, i + 1);
}
}
}
throw new Error(`unterminated ${name}`);
}
test('focused settings editor still autosaves without repainting the focused field', async () => {
const bundle = [
extractFunction('isEditableElementInSettingsCard'),
extractFunction('scheduleSettingsAutoSave'),
extractFunction('saveSettings'),
].join('\n');
const api = new Function(`
class MockElement {
constructor(tagName, type = '') {
this.tagName = tagName;
this.type = type;
this.isContentEditable = false;
}
}
const Element = MockElement;
const focusedInput = new MockElement('input', 'text');
const settingsCard = { contains: (element) => element === focusedInput };
const document = { activeElement: focusedInput };
let settingsAutoSaveTimer = null;
let scheduled = null;
let clearedTimer = null;
let settingsDirty = true;
let settingsSaveInFlight = false;
let settingsSaveRevision = 1;
const messages = [];
const syncedStates = [];
const dirtyMarks = [];
let applyCalls = 0;
function setTimeout(fn, delay) {
scheduled = { fn, delay };
return 71;
}
function clearTimeout(value) {
clearedTimer = value;
}
function collectSettingsPayload() {
return { vpsUrl: 'typed-value' };
}
function syncLatestState(state) {
syncedStates.push(state);
}
function markSettingsDirty(value = true) {
settingsDirty = value;
if (value) settingsSaveRevision += 1;
dirtyMarks.push(value);
}
function applySettingsState() {
applyCalls += 1;
}
function updateSaveButtonState() {}
function updatePanelModeUI() {}
function updateMailProviderUI() {}
function updateButtonStates() {}
const chrome = {
runtime: {
async sendMessage(message) {
messages.push(message);
return { state: { vpsUrl: message.payload.vpsUrl } };
},
},
};
${bundle}
return {
scheduleSettingsAutoSave,
async fireScheduled() {
await scheduled.fn();
},
getSnapshot() {
return {
applyCalls,
clearedTimer,
dirtyMarks,
messages,
scheduledDelay: scheduled?.delay,
syncedStates,
};
},
};
`)();
api.scheduleSettingsAutoSave();
let snapshot = api.getSnapshot();
assert.equal(snapshot.scheduledDelay, 1200);
assert.equal(snapshot.messages.length, 0);
await api.fireScheduled();
snapshot = api.getSnapshot();
assert.equal(snapshot.clearedTimer, 71);
assert.equal(snapshot.applyCalls, 0);
assert.deepEqual(snapshot.messages, [
{
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { vpsUrl: 'typed-value' },
},
]);
assert.deepEqual(snapshot.syncedStates, [{ vpsUrl: 'typed-value' }]);
assert.deepEqual(snapshot.dirtyMarks, [false]);
});
@@ -4,6 +4,7 @@ const fs = require('node:fs');
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
const flowRegistrySource = fs.readFileSync('shared/flow-registry.js', 'utf8');
test('sidepanel exposes SUB2API account priority below group setting', () => {
assert.match(html, /id="row-sub2api-account-priority"/);
@@ -39,7 +40,8 @@ test('sidepanel persists and locks SUB2API account priority setting', () => {
source,
/inputSub2ApiAccountPriority\.value = String\(normalizeSub2ApiAccountPriorityValue\(state\?\.sub2apiAccountPriority\)\);/
);
assert.match(source, /rowSub2ApiAccountPriority\.style\.display = useSub2Api \? '' : 'none';/);
assert.match(source, /applyFlowSettingsGroupVisibility\(visibleGroupIds\);/);
assert.match(flowRegistrySource, /'openai-target-sub2api': \{[\s\S]*'row-sub2api-account-priority'/);
assert.match(source, /inputSub2ApiAccountPriority\.disabled = locked;/);
assert.match(
source,
+86
View File
@@ -0,0 +1,86 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/signup-page.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 i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
const api = new Function(`
${extractFunction('resolveCommandNodeId')}
return {
resolveCommandNodeId,
};
`)();
test('signup page resolves bound-email relogin verification nodes from dynamic visible steps', () => {
assert.equal(
api.resolveCommandNodeId({
type: 'FILL_CODE',
step: 8,
payload: { visibleStep: 15 },
}),
'fetch-bound-email-login-code'
);
assert.equal(
api.resolveCommandNodeId({
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
step: 16,
payload: { visibleStep: 16 },
}),
'post-bound-email-phone-verification'
);
assert.equal(
api.resolveCommandNodeId({
type: 'FILL_CODE',
step: 8,
payload: { nodeId: 'fetch-bound-email-login-code', visibleStep: 15 },
}),
'fetch-bound-email-login-code'
);
});
+19
View File
@@ -209,6 +209,25 @@ return {
assert.deepStrictEqual(snapshot.removedBatches, [[11, 12]]);
assert.strictEqual(snapshot.currentState.tabRegistry['signup-page'], null);
api.reset({
tabs: [
{ id: 21, active: true, url: 'https://chatgpt.com/' },
{ id: 22, active: false, url: 'https://auth.openai.com/authorize?client_id=test' },
],
state: {
sourceLastUrls: {
'signup-page': 'https://auth.openai.com/authorize?client_id=test',
},
tabRegistry: {},
},
});
await api.closeConflictingTabsForSource('signup-page', 'https://chatgpt.com/');
snapshot = api.snapshot();
assert.deepStrictEqual(snapshot.removedBatches, [[21, 22]]);
assert.deepStrictEqual(snapshot.currentTabs, []);
console.log('signup page tab cleanup tests passed');
})().catch((error) => {
console.error(error);
+122
View File
@@ -440,6 +440,128 @@ return {
assert.equal(api.run()?.kind, 'localized-email');
});
test('findSignupEntryTrigger recognizes Japanese free signup button text', () => {
const api = new Function(`
const signupButton = {
textContent: '無料でサインアップ',
value: '',
disabled: false,
getAttribute(name) {
if (name === 'type') return 'button';
return '';
},
getBoundingClientRect() {
return { width: 180, height: 48 };
},
};
const loginButton = {
textContent: 'ログイン',
value: '',
disabled: false,
getAttribute(name) {
if (name === 'type') return 'button';
return '';
},
getBoundingClientRect() {
return { width: 120, height: 48 };
},
};
const document = {
querySelectorAll(selector) {
if (selector === 'a, button, [role="button"], [role="link"]') {
return [loginButton, signupButton];
}
return [];
},
};
${extractConst('SIGNUP_ENTRY_TRIGGER_PATTERN')}
function isVisibleElement(el) {
return Boolean(el);
}
function isActionEnabled(el) {
return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
}
function getActionText(el) {
return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')]
.filter(Boolean)
.join(' ')
.replace(/\\s+/g, ' ')
.trim();
}
${extractFunction('findSignupEntryTrigger')}
return {
run() {
return getActionText(findSignupEntryTrigger());
},
};
`)();
assert.equal(api.run(), '無料でサインアップ');
});
test('getSignupEmailContinueButton recognizes Japanese continue text', () => {
const api = new Function(`
const continueButton = {
textContent: '続ける',
value: '',
disabled: false,
getAttribute(name) {
if (name === 'type') return 'button';
return '';
},
getBoundingClientRect() {
return { width: 180, height: 48 };
},
};
const document = {
querySelector() {
return null;
},
querySelectorAll(selector) {
if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
return [continueButton];
}
return [];
},
};
function isVisibleElement(el) {
return Boolean(el);
}
function isActionEnabled(el) {
return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
}
function getActionText(el) {
return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')]
.filter(Boolean)
.join(' ')
.replace(/\\s+/g, ' ')
.trim();
}
${extractFunction('getSignupEmailContinueButton')}
return {
run() {
return getActionText(getSignupEmailContinueButton());
},
};
`)();
assert.equal(api.run(), '続ける');
});
test('waitForSignupEntryState retries the signup entry click five times before giving up', async () => {
const api = new Function(`
const logs = [];
+94 -16
View File
@@ -2,9 +2,20 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadSourceRegistry() {
const flowRegistrySource = fs.readFileSync('shared/flow-registry.js', 'utf8');
const sourceRegistrySource = fs.readFileSync('shared/source-registry.js', 'utf8');
const globalScope = {};
new Function('self', `${flowRegistrySource}; ${sourceRegistrySource}; return self;`)(globalScope);
return globalScope.MultiPageSourceRegistry.createSourceRegistry();
}
test('background imports shared source registry module', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /shared\/flow-registry\.js/);
assert.match(source, /shared\/settings-schema\.js/);
assert.match(source, /shared\/source-registry\.js/);
assert.match(source, /shared\/kiro-timeouts\.js/);
});
test('manifest loads shared source registry before content utils in static bundles', () => {
@@ -20,22 +31,36 @@ test('manifest loads shared source registry before content utils in static bundl
}
});
test('shared source registry exposes canonical source, alias, detection, and ready policies', () => {
const source = fs.readFileSync('shared/source-registry.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageSourceRegistry;`)({});
const registry = api.createSourceRegistry();
test('manifest no longer ships a static Kiro content bundle', () => {
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
const hasStaticKiroBundle = (manifest.content_scripts || []).some((entry) => {
const scripts = Array.isArray(entry.js) ? entry.js : [];
return scripts.includes('content/kiro/register-page.js')
|| scripts.includes('content/kiro/desktop-authorize-page.js');
});
assert.equal(hasStaticKiroBundle, false);
});
test('background injects shared Kiro timeout module before Kiro content scripts', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(
source,
/const KIRO_REGISTER_INJECT_FILES = \['shared\/source-registry\.js', 'shared\/kiro-timeouts\.js', 'content\/utils\.js', 'content\/kiro\/register-page\.js'\];/
);
assert.match(
source,
/const KIRO_DESKTOP_AUTHORIZE_INJECT_FILES = \['shared\/source-registry\.js', 'shared\/kiro-timeouts\.js', 'content\/utils\.js', 'content\/kiro\/desktop-authorize-page\.js'\];/
);
});
test('shared source registry exposes canonical Kiro sources and drivers', () => {
const registry = loadSourceRegistry();
assert.equal(registry.resolveCanonicalSource('signup-page'), 'openai-auth');
assert.deepEqual(registry.getSourceKeys('signup-page'), ['openai-auth', 'signup-page']);
assert.equal(registry.getSourceLabel('openai-auth'), '认证页');
assert.equal(
registry.matchesSourceUrlFamily(
'openai-auth',
'https://chatgpt.com/',
'https://auth.openai.com/authorize?client_id=test'
),
true
);
assert.equal(
registry.detectSourceFromLocation({
url: 'https://auth.openai.com/create-account',
@@ -43,6 +68,21 @@ test('shared source registry exposes canonical source, alias, detection, and rea
}),
'openai-auth'
);
assert.equal(
registry.detectSourceFromLocation({
url: 'https://app.kiro.dev/signin',
hostname: 'app.kiro.dev',
}),
'kiro-register-page'
);
assert.equal(
registry.detectSourceFromLocation({
injectedSource: 'kiro-desktop-authorize',
url: 'https://signin.aws/register',
hostname: 'signin.aws',
}),
'kiro-desktop-authorize'
);
assert.equal(
registry.detectSourceFromLocation({
url: 'https://example.com/',
@@ -50,13 +90,51 @@ test('shared source registry exposes canonical source, alias, detection, and rea
}),
'unknown-source'
);
assert.equal(
registry.matchesSourceUrlFamily(
'kiro-register-page',
'https://app.kiro.dev/signin',
'https://app.kiro.dev/signin'
),
true
);
assert.equal(
registry.matchesSourceUrlFamily(
'kiro-register-page',
'https://signin.aws/register',
'https://app.kiro.dev/signin'
),
true
);
assert.equal(
registry.matchesSourceUrlFamily(
'kiro-desktop-authorize',
'https://profile.aws/complete',
'https://signin.aws/register'
),
true
);
assert.equal(
registry.matchesSourceUrlFamily(
'kiro-desktop-authorize',
'https://oidc.us-east-1.amazonaws.com/authorize',
'https://view.awsapps.com/start'
),
true
);
assert.equal(registry.shouldReportReadyForFrame('mail-163', true), false);
assert.equal(registry.shouldReportReadyForFrame('unknown-source', false), false);
assert.equal(registry.shouldReportReadyForFrame('kiro-register-page', true), false);
assert.equal(registry.shouldReportReadyForFrame('kiro-desktop-authorize', true), false);
assert.equal(registry.getCleanupOwnerSource('oauth-localhost-callback'), 'openai-auth');
assert.equal(registry.driverAcceptsCommand('openai-auth', 'submit-signup-email'), true);
assert.equal(registry.driverAcceptsCommand('openai-auth', 'post-login-phone-verification'), true);
assert.equal(registry.driverAcceptsCommand('openai-auth', 'bind-email'), true);
assert.equal(registry.driverAcceptsCommand('openai-auth', 'fetch-bind-email-code'), true);
assert.equal(registry.driverAcceptsCommand('content/platform-panel', 'platform-verify'), true);
assert.equal(registry.driverAcceptsCommand('openai-auth', 'platform-verify'), false);
assert.equal(registry.driverAcceptsCommand('content/kiro/register-page', 'kiro-submit-password'), true);
assert.equal(registry.driverAcceptsCommand('content/kiro/desktop-authorize-page', 'kiro-complete-desktop-authorize'), true);
assert.equal(registry.driverAcceptsCommand('background/kiro-register', 'kiro-open-register-page'), true);
assert.equal(registry.driverAcceptsCommand('background/kiro-desktop-authorize', 'kiro-start-desktop-authorize'), true);
assert.equal(registry.driverAcceptsCommand('background/kiro-publisher-kiro-rs', 'kiro-upload-credential'), true);
});
+192 -1
View File
@@ -22,6 +22,7 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
});
const goPaySteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'gopay' });
const gpcSteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'gpc-helper' });
const kiroSteps = api.getSteps({ activeFlowId: 'kiro' });
assert.equal(Array.isArray(steps), true);
assert.equal(steps.length, 11);
@@ -158,10 +159,55 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true, signupMethod: 'phone', phoneSignupReloginAfterBindEmailEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
assert.equal(api.getLastStepId({ plusModeEnabled: true, signupMethod: 'phone', phoneSignupReloginAfterBindEmailEnabled: true }), 18);
assert.equal(api.hasFlow('openai'), true);
assert.equal(api.hasFlow('kiro'), true);
assert.equal(api.hasFlow('site-a'), false);
assert.deepStrictEqual(api.getRegisteredFlowIds(), ['openai']);
assert.deepStrictEqual(api.getRegisteredFlowIds(), ['openai', 'kiro']);
assert.deepStrictEqual(api.getSteps({ activeFlowId: 'site-a' }), []);
assert.equal(api.getStepById(2, { activeFlowId: 'site-a' }), null);
assert.deepStrictEqual(
kiroSteps.map((step) => step.key),
[
'kiro-open-register-page',
'kiro-submit-email',
'kiro-submit-name',
'kiro-submit-verification-code',
'kiro-submit-password',
'kiro-complete-register-consent',
'kiro-start-desktop-authorize',
'kiro-complete-desktop-authorize',
'kiro-upload-credential',
]
);
assert.equal(kiroSteps.every((step) => step.flowId === 'kiro'), true);
assert.equal(kiroSteps[0].driverId, 'background/kiro-register');
assert.equal(kiroSteps[8].sourceId, 'kiro-rs-admin');
assert.equal(kiroSteps[0].title, '打开注册页');
assert.equal(kiroSteps[1].title, '获取邮箱并继续');
assert.equal(kiroSteps[2].title, '填写姓名并继续');
assert.equal(kiroSteps[3].title, '获取验证码并继续');
assert.equal(kiroSteps[4].title, '设置密码并继续');
assert.equal(kiroSteps[5].title, '完成注册授权');
assert.equal(kiroSteps[6].title, '启动桌面授权');
assert.equal(kiroSteps[7].title, '完成桌面授权');
assert.equal(kiroSteps[8].title, '上传凭据到 kiro.rs');
const kiroContributionSteps = api.getSteps({ activeFlowId: 'kiro', accountContributionEnabled: true });
assert.equal(kiroContributionSteps[8].title, '贡献上传');
assert.deepStrictEqual(api.getStepIds({ activeFlowId: 'kiro' }), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assert.equal(api.getLastStepId({ activeFlowId: 'kiro' }), 9);
assert.deepStrictEqual(
api.getNodes({ activeFlowId: 'kiro' }).map((node) => node.next),
[
['kiro-submit-email'],
['kiro-submit-name'],
['kiro-submit-verification-code'],
['kiro-submit-password'],
['kiro-complete-register-consent'],
['kiro-start-desktop-authorize'],
['kiro-complete-desktop-authorize'],
['kiro-upload-credential'],
[],
]
);
assert.equal(plusSteps[5].title, '创建 Plus Checkout');
assert.equal(plusSteps[7].title, 'PayPal 登录与授权');
@@ -210,6 +256,151 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
assert.equal(gpcSteps[6].title, '等待 GPC 任务完成');
});
test('Plus session strategy swaps the OAuth tail for a single SUB2API 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: 'sub2api_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: 'sub2api_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: 'sub2api_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 === 'sub2api-session-import');
assert.equal(stepKeys.at(-1), 'sub2api-session-import', `${label} should end with session import`);
assert.equal(nodeIds.at(-1), 'sub2api-session-import', `${label} node order should end with session import`);
forbiddenTailKeys.forEach((key) => {
assert.equal(stepKeys.includes(key), false, `${label} should not keep ${key} in session mode`);
assert.equal(nodeIds.includes(key), false, `${label} nodes should not keep ${key} in session mode`);
});
assert.deepStrictEqual(api.getStepIds(options), expectedStepIds, `${label} step ids should follow the new tail`);
assert.equal(api.getLastStepId(options), expectedStepIds.at(-1), `${label} last step id should match session import`);
assert.deepStrictEqual(previousNode?.next, ['sub2api-session-import'], `${label} previous node should link to session import`);
assert.deepStrictEqual(sessionImportNode?.next, [], `${label} session import should be terminal`);
});
});
test('Plus phone signup never switches to SUB2API session tail even if the requested strategy is session import', () => {
const source = fs.readFileSync('data/step-definitions.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageStepDefinitions;`)(globalScope);
const steps = api.getSteps({
plusModeEnabled: true,
plusPaymentMethod: 'paypal',
signupMethod: 'phone',
plusAccountAccessStrategy: 'sub2api_codex_session',
});
const stepKeys = steps.map((step) => step.key);
assert.equal(stepKeys.includes('sub2api-session-import'), false);
assert.equal(stepKeys.includes('oauth-login'), true);
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>');
+51
View File
@@ -1682,6 +1682,57 @@ test('verification flow does not replay step 8 code submit after transient auth-
assert.deepStrictEqual(resilientMessages, ['GET_LOGIN_AUTH_STATE']);
});
test('verification flow forwards dynamic completion node id when submitting bound-email login code', async () => {
const directCalls = [];
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
getNodeIdByStepForState: (step) => ({
14: 'fetch-bound-email-login-code',
})[Number(step)] || '',
getState: async () => ({}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isStopError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
pollCloudflareTempEmailVerificationCode: async () => ({}),
pollHotmailVerificationCode: async () => ({}),
pollLuckmailVerificationCode: async () => ({}),
sendToContentScript: async (_source, message) => {
directCalls.push(message);
return { success: true };
},
sendToContentScriptResilient: async () => {
throw new Error('step 8 code submit should use the direct channel once');
},
sendToMailContentScriptResilient: async () => ({}),
setState: async () => {},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await helpers.submitVerificationCode(8, '246810', { completionStep: 14 });
assert.equal(directCalls.length, 1);
assert.equal(directCalls[0].type, 'FILL_CODE');
assert.equal(directCalls[0].payload.code, '246810');
assert.equal(directCalls[0].payload.nodeId, 'fetch-bound-email-login-code');
});
test('verification flow keeps step 8 code submit response timeout above local floor when flow budget is nearly exhausted', async () => {
const directCalls = [];