修复设置自动保存与标签清理边界
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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]';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: '',",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}]);
|
||||
|
||||
@@ -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 也已同时跳过。'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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' } }]);
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user