feat: continue OAuth flow through HeroSMS phone verification
- 合并 dev 最新主线改动:同步当前认证页恢复、来源配置和侧栏能力的现有基线 - 本地补充修复:补齐 HeroSMS 手机验证与 sidepanel 初始化冲突,修正 Step 9 等待逻辑里的未声明变量,并同步结构/链路文档与回归测试 - 影响范围:OAuth 步骤 8/9、HeroSMS 配置、认证页内容脚本、侧栏初始化、项目文档与测试
This commit is contained in:
@@ -81,6 +81,16 @@ test('isRecoverableStep9AuthFailure matches timeout and CPA auth failure statuse
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isRecoverableStep9AuthFailure('提交回调失败:请更新CLI Proxy API或检查连接'),
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isRecoverableStep9AuthFailure('认证失败:Bad Request'),
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isRecoverableStep9AuthFailure('认证成功!'),
|
||||
false
|
||||
|
||||
@@ -331,3 +331,168 @@ test('auto-run controller skips user_already_exists failures to the next round i
|
||||
assert.equal(runtime.state.autoRunActive, false);
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
|
||||
test('auto-run controller keeps retrying the same custom mail provider pool email until success', async () => {
|
||||
const events = {
|
||||
logs: [],
|
||||
broadcasts: [],
|
||||
accountRecords: [],
|
||||
runCalls: 0,
|
||||
};
|
||||
|
||||
let currentState = {
|
||||
stepStatuses: {},
|
||||
vpsUrl: 'https://example.com/vps',
|
||||
vpsPassword: 'secret',
|
||||
customPassword: '',
|
||||
autoRunSkipFailures: true,
|
||||
autoRunFallbackThreadIntervalMinutes: 0,
|
||||
autoRunDelayEnabled: false,
|
||||
autoRunDelayMinutes: 30,
|
||||
autoStepDelaySeconds: null,
|
||||
mailProvider: 'custom',
|
||||
customMailProviderPool: ['first@example.com'],
|
||||
emailGenerator: 'duck',
|
||||
gmailBaseEmail: '',
|
||||
mail2925BaseEmail: '',
|
||||
emailPrefix: 'demo',
|
||||
inbucketHost: '',
|
||||
inbucketMailbox: '',
|
||||
cloudflareDomain: '',
|
||||
cloudflareDomains: [],
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
autoRunRoundSummaries: [],
|
||||
};
|
||||
|
||||
const runtime = {
|
||||
state: {
|
||||
autoRunActive: false,
|
||||
autoRunCurrentRun: 0,
|
||||
autoRunTotalRuns: 1,
|
||||
autoRunAttemptRun: 0,
|
||||
autoRunSessionId: 0,
|
||||
},
|
||||
get() {
|
||||
return { ...this.state };
|
||||
},
|
||||
set(updates = {}) {
|
||||
this.state = { ...this.state, ...updates };
|
||||
},
|
||||
};
|
||||
|
||||
let sessionSeed = 0;
|
||||
|
||||
const controller = api.createAutoRunController({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
appendAccountRunRecord: async (status, _state, reason) => {
|
||||
events.accountRecords.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.broadcasts.push({ phase, ...payload });
|
||||
currentState = {
|
||||
...currentState,
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
|
||||
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
|
||||
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
|
||||
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
|
||||
};
|
||||
},
|
||||
broadcastStopToContentScripts: async () => {},
|
||||
cancelPendingCommands: () => {},
|
||||
clearStopRequest: () => {},
|
||||
createAutoRunSessionId: () => {
|
||||
sessionSeed += 1;
|
||||
return sessionSeed;
|
||||
},
|
||||
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 || ''),
|
||||
getFirstUnfinishedStep: () => 1,
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getRunningSteps: () => [],
|
||||
getState: async () => ({
|
||||
...currentState,
|
||||
stepStatuses: { ...(currentState.stepStatuses || {}) },
|
||||
tabRegistry: { ...(currentState.tabRegistry || {}) },
|
||||
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
|
||||
customMailProviderPool: [...(currentState.customMailProviderPool || [])],
|
||||
}),
|
||||
getStopRequested: () => false,
|
||||
hasSavedProgress: () => false,
|
||||
isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')),
|
||||
isRestartCurrentAttemptError: () => false,
|
||||
isSignupUserAlreadyExistsFailure: (error) => /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(error?.message || String(error || '')),
|
||||
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
|
||||
launchAutoRunTimerPlan: async () => false,
|
||||
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
|
||||
persistAutoRunTimerPlan: async () => ({}),
|
||||
resetState: async () => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
stepStatuses: {},
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
};
|
||||
},
|
||||
runAutoSequenceFromStep: async () => {
|
||||
events.runCalls += 1;
|
||||
if (events.runCalls <= 2) {
|
||||
throw new Error('步骤 3:页面异常,当前尝试失败。');
|
||||
}
|
||||
},
|
||||
runtime,
|
||||
setState: async (updates = {}) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
|
||||
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
|
||||
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
|
||||
};
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfAutoRunSessionStopped: (sessionId) => {
|
||||
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
|
||||
throw new Error('流程已被用户停止。');
|
||||
}
|
||||
},
|
||||
waitForRunningStepsToFinish: async () => currentState,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.autoRunLoop(1, {
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
});
|
||||
|
||||
assert.equal(events.runCalls, 3, 'custom mail provider pool should keep retrying the same round until success');
|
||||
assert.equal(events.broadcasts.filter(({ phase }) => phase === 'retrying').length, 2);
|
||||
assert.ok(events.broadcasts.filter(({ phase }) => phase === 'retrying').every(({ currentRun }) => currentRun === 1));
|
||||
assert.ok(events.logs.some(({ message }) => /继续使用当前邮箱/.test(message)));
|
||||
assert.equal(events.logs.some(({ message }) => /达到 3 次重试上限,继续下一轮/.test(message)), false);
|
||||
assert.equal(events.accountRecords.length, 0);
|
||||
assert.equal(runtime.state.autoRunActive, false);
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
|
||||
@@ -330,3 +330,124 @@ return {
|
||||
assert.deepStrictEqual(result.events.steps, [1, 2, 3, 4]);
|
||||
assert.equal(result.events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), false);
|
||||
});
|
||||
|
||||
test('auto-run skips steps 4/5 when step 2 has already marked registration chain as skipped', async () => {
|
||||
const api = new Function(`
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const chrome = {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => {},
|
||||
},
|
||||
};
|
||||
|
||||
let currentState = {
|
||||
email: 'already@login.example',
|
||||
password: 'Secret123!',
|
||||
mailProvider: 'icloud',
|
||||
stepStatuses: {
|
||||
1: 'pending',
|
||||
2: 'pending',
|
||||
3: 'pending',
|
||||
4: 'pending',
|
||||
5: 'pending',
|
||||
6: 'pending',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
};
|
||||
const events = {
|
||||
steps: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
async function addLog(message, level = 'info') {
|
||||
events.logs.push({ message, level });
|
||||
}
|
||||
|
||||
async function ensureAutoEmailReady() {
|
||||
return currentState.email;
|
||||
}
|
||||
|
||||
async function broadcastAutoRunStatus() {}
|
||||
|
||||
async function getState() {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
async function setState(updates) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
function isStopError(error) {
|
||||
return (error?.message || String(error || '')) === '流程已被用户停止。';
|
||||
}
|
||||
|
||||
function isStepDoneStatus(status) {
|
||||
return status === 'completed' || status === 'manual_completed' || status === 'skipped';
|
||||
}
|
||||
|
||||
async function executeStepAndWait(step) {
|
||||
events.steps.push(step);
|
||||
if (step === 2) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
stepStatuses: {
|
||||
...currentState.stepStatuses,
|
||||
3: 'skipped',
|
||||
4: 'skipped',
|
||||
5: 'skipped',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getTabId() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function invalidateDownstreamAfterStepRestart() {}
|
||||
|
||||
function getLoginAuthStateLabel(state) {
|
||||
return state || 'unknown';
|
||||
}
|
||||
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
|
||||
async function getLoginAuthStateFromContent() {
|
||||
return { state: 'password_page', url: 'https://auth.openai.com/log-in' };
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
await runAutoSequenceFromStep(1, {
|
||||
targetRun: 1,
|
||||
totalRuns: 1,
|
||||
attemptRuns: 1,
|
||||
continued: false,
|
||||
});
|
||||
return { events, currentState };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const { events } = await api.run();
|
||||
|
||||
assert.deepStrictEqual(events.steps, [1, 2, 6, 7, 8, 9, 10]);
|
||||
assert.equal(events.logs.some(({ message }) => /步骤 4 当前状态为 skipped/.test(message)), true);
|
||||
assert.equal(events.logs.some(({ message }) => /步骤 5 当前状态为 skipped/.test(message)), true);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ function extractFunction(name) {
|
||||
|
||||
test('background account history settings are normalized independently from hotmail service mode', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeCodex2ApiUrl'),
|
||||
extractFunction('normalizeHotmailLocalBaseUrl'),
|
||||
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
|
||||
extractFunction('normalizeVerificationResendCount'),
|
||||
@@ -60,6 +61,7 @@ test('background account history settings are normalized independently from hotm
|
||||
const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
|
||||
const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL;
|
||||
const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
|
||||
const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts';
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
const DEFAULT_SUB2API_PROXY_NAME = '';
|
||||
const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
|
||||
@@ -70,7 +72,7 @@ const PERSISTED_SETTING_DEFAULTS = {
|
||||
autoStepDelaySeconds: null,
|
||||
mailProvider: '163',
|
||||
};
|
||||
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
|
||||
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); }
|
||||
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
|
||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
|
||||
@@ -118,4 +120,16 @@ return {
|
||||
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
|
||||
'proxy-a'
|
||||
);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('codex2apiUrl', 'localhost:8080/admin'),
|
||||
'http://localhost:8080/admin/accounts'
|
||||
);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('codex2apiUrl', 'https://codex-admin.example.com/'),
|
||||
'https://codex-admin.example.com/admin/accounts'
|
||||
);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('codex2apiAdminKey', ' secret-key '),
|
||||
'secret-key'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ test('executeStep reuses the active top-level auth chain instead of starting a d
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
|
||||
const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
|
||||
let activeTopLevelAuthChainExecution = null;
|
||||
let stopRequested = false;
|
||||
@@ -106,6 +107,10 @@ function isTerminalSecurityBlockedError() {
|
||||
return false;
|
||||
}
|
||||
async function handleCloudflareSecurityBlocked() {}
|
||||
function isBrowserSwitchRequiredError() {
|
||||
return false;
|
||||
}
|
||||
async function handleBrowserSwitchRequired() {}
|
||||
function doesStepUseCompletionSignal() {
|
||||
return false;
|
||||
}
|
||||
@@ -159,10 +164,99 @@ return {
|
||||
assert.ok(events.logs.some(({ message }) => /复用当前授权链/.test(message)));
|
||||
});
|
||||
|
||||
test('executeStep stops flow when browser-switch-required error is raised', async () => {
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
|
||||
const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
|
||||
let activeTopLevelAuthChainExecution = null;
|
||||
let stopRequested = false;
|
||||
const events = {
|
||||
logs: [],
|
||||
statusCalls: [],
|
||||
stopRequests: [],
|
||||
appendRecords: [],
|
||||
};
|
||||
|
||||
async function addLog(message, level = 'info') {
|
||||
events.logs.push({ message, level });
|
||||
}
|
||||
async function setStepStatus(step, status) {
|
||||
events.statusCalls.push({ step, status });
|
||||
}
|
||||
async function humanStepDelay() {}
|
||||
async function getState() {
|
||||
return {
|
||||
flowStartTime: null,
|
||||
stepStatuses: {},
|
||||
};
|
||||
}
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
async function appendManualAccountRunRecordIfNeeded(status, _state, reason) {
|
||||
events.appendRecords.push({ status, reason });
|
||||
}
|
||||
function isTerminalSecurityBlockedError() {
|
||||
return false;
|
||||
}
|
||||
async function handleCloudflareSecurityBlocked() {}
|
||||
async function requestStop(options = {}) {
|
||||
events.stopRequests.push(options);
|
||||
}
|
||||
function doesStepUseCompletionSignal() {
|
||||
return false;
|
||||
}
|
||||
function isRetryableContentScriptTransportError() {
|
||||
return false;
|
||||
}
|
||||
const stepRegistry = {
|
||||
async executeStep() {
|
||||
throw new Error('BROWSER_SWITCH_REQUIRED::请更换浏览器进行注册登录。');
|
||||
},
|
||||
};
|
||||
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
${extractFunction('isAuthChainStep')}
|
||||
${extractFunction('acquireTopLevelAuthChainExecution')}
|
||||
${extractFunction('isBrowserSwitchRequiredError')}
|
||||
${extractFunction('getBrowserSwitchRequiredMessage')}
|
||||
${extractFunction('handleBrowserSwitchRequired')}
|
||||
${extractFunction('executeStep')}
|
||||
|
||||
return {
|
||||
executeStep,
|
||||
snapshot() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.executeStep(10),
|
||||
/流程已被用户停止。/
|
||||
);
|
||||
|
||||
const events = api.snapshot();
|
||||
assert.deepStrictEqual(events.stopRequests, [
|
||||
{ logMessage: '请更换浏览器进行注册登录。' },
|
||||
]);
|
||||
assert.deepStrictEqual(events.statusCalls, [
|
||||
{ step: 10, status: 'running' },
|
||||
]);
|
||||
assert.equal(
|
||||
events.logs.some(({ message }) => /步骤 10 失败/.test(message)),
|
||||
false,
|
||||
'browser-switch-required error should stop the flow before generic failed logging'
|
||||
);
|
||||
});
|
||||
|
||||
test('oauth timeout budget ignores stale deadlines from an old oauth url', async () => {
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
const OAUTH_FLOW_TIMEOUT_MS = 6 * 60 * 1000;
|
||||
const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
${extractFunction('normalizeOAuthFlowDeadlineAt')}
|
||||
${extractFunction('normalizeOAuthFlowSourceUrl')}
|
||||
${extractFunction('getOAuthFlowRemainingMs')}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < source.length; end += 1) {
|
||||
const ch = source[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('cloudflare temp email settings normalize and expose the random-subdomain toggle', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeCloudflareTempEmailReceiveMailbox'),
|
||||
extractFunction('getCloudflareTempEmailConfig'),
|
||||
extractFunction('normalizePersistentSettingValue'),
|
||||
extractFunction('buildPersistentSettingsPayload'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
const PERSISTED_SETTING_DEFAULTS = {
|
||||
panelMode: 'cpa',
|
||||
autoStepDelaySeconds: null,
|
||||
verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT,
|
||||
mailProvider: '163',
|
||||
mail2925Mode: 'provide',
|
||||
emailGenerator: 'duck',
|
||||
autoDeleteUsedIcloudAlias: false,
|
||||
accountRunHistoryTextEnabled: false,
|
||||
cloudflareTempEmailUseRandomSubdomain: false,
|
||||
cloudflareTempEmailDomain: '',
|
||||
cloudflareTempEmailDomains: [],
|
||||
};
|
||||
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
|
||||
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
|
||||
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
|
||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
|
||||
function normalizeAutoStepDelaySeconds(value, fallback = null) { return value == null || value === '' ? fallback : Number(value); }
|
||||
function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
|
||||
function normalizeMailProvider(value) { return String(value || '').trim().toLowerCase() || '163'; }
|
||||
function normalizeMail2925Mode(value) { return String(value || '').trim().toLowerCase() === 'receive' ? 'receive' : 'provide'; }
|
||||
function normalizeEmailGenerator(value) { return String(value || '').trim().toLowerCase() || 'duck'; }
|
||||
function normalizeIcloudHost(value) { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'icloud.com' || normalized === 'icloud.com.cn' ? normalized : ''; }
|
||||
function normalizeAccountRunHistoryHelperBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeHotmailServiceMode(value) { return String(value || '').trim().toLowerCase() === 'remote' ? 'remote' : 'local'; }
|
||||
function normalizeHotmailRemoteBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeHotmailLocalBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareDomain(value) { return String(value || '').trim().toLowerCase(); }
|
||||
function normalizeCloudflareDomains(value) { return Array.isArray(value) ? value.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; }
|
||||
function normalizeCloudflareTempEmailBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailAddress(value = '') { return String(value || '').trim().toLowerCase(); }
|
||||
function normalizeCloudflareTempEmailDomain(value) { return String(value || '').trim().toLowerCase(); }
|
||||
function normalizeCloudflareTempEmailDomains(value) {
|
||||
const seen = new Set();
|
||||
const domains = [];
|
||||
for (const item of Array.isArray(value) ? value : []) {
|
||||
const normalized = normalizeCloudflareTempEmailDomain(item);
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
domains.push(normalized);
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
function normalizeHotmailAccounts(value) { return Array.isArray(value) ? value : []; }
|
||||
function normalizeMail2925Accounts(value) { return Array.isArray(value) ? value : []; }
|
||||
function resolveLegacyAutoStepDelaySeconds() { return undefined; }
|
||||
${bundle}
|
||||
return {
|
||||
buildPersistentSettingsPayload,
|
||||
getCloudflareTempEmailConfig,
|
||||
normalizePersistentSettingValue,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.normalizePersistentSettingValue('cloudflareTempEmailUseRandomSubdomain', 1), true);
|
||||
|
||||
const payload = api.buildPersistentSettingsPayload({
|
||||
cloudflareTempEmailUseRandomSubdomain: true,
|
||||
cloudflareTempEmailDomain: 'mail.example.com',
|
||||
cloudflareTempEmailDomains: ['mail.example.com', 'alt.example.com'],
|
||||
});
|
||||
assert.equal(payload.cloudflareTempEmailUseRandomSubdomain, true);
|
||||
assert.equal(payload.cloudflareTempEmailDomain, 'mail.example.com');
|
||||
assert.deepEqual(payload.cloudflareTempEmailDomains, ['mail.example.com', 'alt.example.com']);
|
||||
|
||||
const config = api.getCloudflareTempEmailConfig({
|
||||
cloudflareTempEmailBaseUrl: 'https://temp.example.com',
|
||||
cloudflareTempEmailAdminAuth: 'admin-secret',
|
||||
cloudflareTempEmailCustomAuth: 'custom-secret',
|
||||
cloudflareTempEmailReceiveMailbox: 'Forward@Example.com',
|
||||
cloudflareTempEmailUseRandomSubdomain: true,
|
||||
cloudflareTempEmailDomain: 'mail.example.com',
|
||||
cloudflareTempEmailDomains: ['mail.example.com'],
|
||||
});
|
||||
assert.deepEqual(config, {
|
||||
baseUrl: 'https://temp.example.com',
|
||||
adminAuth: 'admin-secret',
|
||||
customAuth: 'custom-secret',
|
||||
receiveMailbox: 'forward@example.com',
|
||||
useRandomSubdomain: true,
|
||||
domain: 'mail.example.com',
|
||||
domains: ['mail.example.com'],
|
||||
});
|
||||
});
|
||||
@@ -423,6 +423,7 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
|
||||
assert.match(fetchCalls[0].url, /\/start$/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"nickname":""/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"qq":""/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"email":"user@example\.com"/);
|
||||
assert.match(fetchCalls[1].url, /\/status\?/);
|
||||
|
||||
const callbackState = await manager.handleCapturedCallback(
|
||||
@@ -536,7 +537,7 @@ return { refreshOAuthUrlBeforeStep6 };
|
||||
delete globalThis.LOG_PREFIX;
|
||||
});
|
||||
|
||||
test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API path explicitly when contributionMode=false', async () => {
|
||||
test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API/Codex2API path explicitly when contributionMode=false', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
|
||||
const calls = [];
|
||||
|
||||
@@ -574,7 +575,7 @@ return { refreshOAuthUrlBeforeStep6 };
|
||||
|
||||
assert.equal(oauthUrl, 'https://panel.example.com/oauth');
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' },
|
||||
{ type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' },
|
||||
{ type: 'panel' },
|
||||
{
|
||||
type: 'step',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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);
|
||||
}
|
||||
|
||||
const bundle = [
|
||||
extractFunction('normalizeEmailGenerator'),
|
||||
extractFunction('normalizeCustomEmailPool'),
|
||||
extractFunction('getCustomEmailPool'),
|
||||
extractFunction('getCustomEmailPoolEmailForRun'),
|
||||
extractFunction('getCustomMailProviderPool'),
|
||||
extractFunction('getCustomMailProviderPoolEmailForRun'),
|
||||
extractFunction('getEmailGeneratorLabel'),
|
||||
].join('\n');
|
||||
|
||||
function createApi() {
|
||||
return new Function(`
|
||||
const CUSTOM_EMAIL_POOL_GENERATOR = 'custom-pool';
|
||||
const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email';
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
normalizeEmailGenerator,
|
||||
normalizeCustomEmailPool,
|
||||
getCustomEmailPool,
|
||||
getCustomEmailPoolEmailForRun,
|
||||
getCustomMailProviderPool,
|
||||
getCustomMailProviderPoolEmailForRun,
|
||||
getEmailGeneratorLabel,
|
||||
};
|
||||
`)();
|
||||
}
|
||||
|
||||
test('background recognizes custom email pool generator and label', () => {
|
||||
const api = createApi();
|
||||
|
||||
assert.equal(api.normalizeEmailGenerator('custom-pool'), 'custom-pool');
|
||||
assert.equal(api.getEmailGeneratorLabel('custom-pool'), '自定义邮箱池');
|
||||
});
|
||||
|
||||
test('background normalizes custom email pool input and keeps order', () => {
|
||||
const api = createApi();
|
||||
|
||||
assert.deepEqual(
|
||||
api.normalizeCustomEmailPool(' Foo@Example.com \ninvalid\nbar@example.com;baz@example.com '),
|
||||
['foo@example.com', 'bar@example.com', 'baz@example.com']
|
||||
);
|
||||
});
|
||||
|
||||
test('background selects the matching email for the current auto-run round', () => {
|
||||
const api = createApi();
|
||||
const state = {
|
||||
customEmailPool: ['first@example.com', 'second@example.com', 'third@example.com'],
|
||||
};
|
||||
|
||||
assert.equal(api.getCustomEmailPoolEmailForRun(state, 1), 'first@example.com');
|
||||
assert.equal(api.getCustomEmailPoolEmailForRun(state, 2), 'second@example.com');
|
||||
assert.equal(api.getCustomEmailPoolEmailForRun(state, 4), '');
|
||||
});
|
||||
|
||||
test('background selects the matching custom provider pool email for the current auto-run round', () => {
|
||||
const api = createApi();
|
||||
const state = {
|
||||
customMailProviderPool: ['first@example.com', 'second@example.com', 'third@example.com'],
|
||||
};
|
||||
|
||||
assert.deepEqual(api.getCustomMailProviderPool(state), [
|
||||
'first@example.com',
|
||||
'second@example.com',
|
||||
'third@example.com',
|
||||
]);
|
||||
assert.equal(api.getCustomMailProviderPoolEmailForRun(state, 1), 'first@example.com');
|
||||
assert.equal(api.getCustomMailProviderPoolEmailForRun(state, 3), 'third@example.com');
|
||||
assert.equal(api.getCustomMailProviderPoolEmailForRun(state, 4), '');
|
||||
});
|
||||
@@ -2,24 +2,25 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function loadGeneratedEmailHelpersApi() {
|
||||
const source = fs.readFileSync('background/generated-email-helpers.js', 'utf8');
|
||||
const globalScope = {};
|
||||
return new Function('self', `${source}; return self.MultiPageGeneratedEmailHelpers;`)(globalScope);
|
||||
}
|
||||
|
||||
test('background imports generated email helper module', () => {
|
||||
const source = fs.readFileSync('background.js', 'utf8');
|
||||
assert.match(source, /importScripts\([\s\S]*'background\/generated-email-helpers\.js'/);
|
||||
});
|
||||
|
||||
test('generated email helper module exposes a factory', () => {
|
||||
const source = fs.readFileSync('background/generated-email-helpers.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
const api = new Function('self', `${source}; return self.MultiPageGeneratedEmailHelpers;`)(globalScope);
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
|
||||
assert.equal(typeof api?.createGeneratedEmailHelpers, 'function');
|
||||
});
|
||||
|
||||
test('generated email helper falls back to normal generator when 2925 is in receive mode', async () => {
|
||||
const source = fs.readFileSync('background/generated-email-helpers.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageGeneratedEmailHelpers;`)(globalScope);
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const events = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
@@ -76,3 +77,339 @@ test('generated email helper falls back to normal generator when 2925 is in rece
|
||||
['email', 'duck@example.com'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('generated email helper can read the requested address from custom email pool', async () => {
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const events = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
addLog: async () => {},
|
||||
buildGeneratedAliasEmail: () => {
|
||||
throw new Error('should not build alias');
|
||||
},
|
||||
buildCloudflareTempEmailHeaders: () => ({}),
|
||||
CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
|
||||
CUSTOM_EMAIL_POOL_GENERATOR: 'custom-pool',
|
||||
DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
|
||||
fetch: async () => ({ ok: true, text: async () => '{}' }),
|
||||
fetchIcloudHideMyEmail: async () => {
|
||||
throw new Error('should not use icloud generator');
|
||||
},
|
||||
getCloudflareTempEmailAddressFromResponse: () => '',
|
||||
getCloudflareTempEmailConfig: () => ({ baseUrl: '', adminAuth: '', domain: '' }),
|
||||
getCustomEmailPoolEmail: (state, targetRun) => state.customEmailPool?.[targetRun - 1] || '',
|
||||
getState: async () => ({
|
||||
customEmailPool: ['first@example.com', 'second@example.com'],
|
||||
emailGenerator: 'custom-pool',
|
||||
mailProvider: 'gmail',
|
||||
}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
throw new Error('should not allocate 2925 account');
|
||||
},
|
||||
joinCloudflareTempEmailUrl: () => '',
|
||||
normalizeCloudflareDomain: () => '',
|
||||
normalizeCloudflareTempEmailAddress: () => '',
|
||||
normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
|
||||
isGeneratedAliasProvider: () => false,
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not open duck tab');
|
||||
},
|
||||
setEmailState: async (email) => {
|
||||
events.push(['email', email]);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const email = await helpers.fetchGeneratedEmail({
|
||||
customEmailPool: ['first@example.com', 'second@example.com'],
|
||||
emailGenerator: 'custom-pool',
|
||||
mailProvider: 'gmail',
|
||||
}, {
|
||||
generator: 'custom-pool',
|
||||
poolIndex: 1,
|
||||
});
|
||||
|
||||
assert.equal(email, 'second@example.com');
|
||||
assert.deepStrictEqual(events, [
|
||||
['email', 'second@example.com'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('generated email helper respects runtime generator overrides when deciding alias flow', async () => {
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const aliasStates = [];
|
||||
const savedEmails = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
addLog: async () => {},
|
||||
buildGeneratedAliasEmail: (state) => {
|
||||
aliasStates.push({ ...state });
|
||||
return 'base+tag@gmail.com';
|
||||
},
|
||||
buildCloudflareTempEmailHeaders: () => ({}),
|
||||
CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
|
||||
CUSTOM_EMAIL_POOL_GENERATOR: 'custom-pool',
|
||||
DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
|
||||
fetch: async () => ({ ok: true, text: async () => '{}' }),
|
||||
fetchIcloudHideMyEmail: async () => {
|
||||
throw new Error('should not use icloud generator');
|
||||
},
|
||||
getCloudflareTempEmailAddressFromResponse: () => '',
|
||||
getCloudflareTempEmailConfig: () => ({ baseUrl: '', adminAuth: '', domain: '' }),
|
||||
getCustomEmailPoolEmail: () => '',
|
||||
getState: async () => ({
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'duck',
|
||||
gmailBaseEmail: '',
|
||||
}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
throw new Error('should not allocate mail2925 account');
|
||||
},
|
||||
joinCloudflareTempEmailUrl: () => '',
|
||||
normalizeCloudflareDomain: () => '',
|
||||
normalizeCloudflareTempEmailAddress: () => '',
|
||||
normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
|
||||
isGeneratedAliasProvider: (stateOrProvider, mail2925Mode) => {
|
||||
const provider = typeof stateOrProvider === 'string'
|
||||
? stateOrProvider
|
||||
: stateOrProvider?.mailProvider;
|
||||
const generator = typeof stateOrProvider === 'string'
|
||||
? ''
|
||||
: stateOrProvider?.emailGenerator;
|
||||
return String(provider || '').trim().toLowerCase() === 'gmail'
|
||||
&& String(generator || '').trim().toLowerCase() !== 'custom-pool'
|
||||
&& String(mail2925Mode || '').trim().toLowerCase() !== 'receive';
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not use duck generator');
|
||||
},
|
||||
setEmailState: async (email) => {
|
||||
savedEmails.push(email);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const email = await helpers.fetchGeneratedEmail({
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'duck',
|
||||
}, {
|
||||
generator: 'gmail-alias',
|
||||
mailProvider: 'gmail',
|
||||
gmailBaseEmail: 'base@gmail.com',
|
||||
});
|
||||
|
||||
assert.equal(email, 'base+tag@gmail.com');
|
||||
assert.deepEqual(savedEmails, ['base+tag@gmail.com']);
|
||||
assert.equal(aliasStates.length, 1);
|
||||
assert.equal(aliasStates[0].mailProvider, 'gmail');
|
||||
assert.equal(aliasStates[0].emailGenerator, 'gmail-alias');
|
||||
assert.equal(aliasStates[0].gmailBaseEmail, 'base@gmail.com');
|
||||
});
|
||||
|
||||
test('generated email helper uses the regular temp email domain when random subdomain mode is disabled', async () => {
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const requests = [];
|
||||
const savedEmails = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
addLog: async () => {},
|
||||
buildGeneratedAliasEmail: () => {
|
||||
throw new Error('should not build managed alias');
|
||||
},
|
||||
buildCloudflareTempEmailHeaders: () => ({ 'x-admin-auth': 'admin-secret' }),
|
||||
CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
|
||||
DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
|
||||
fetch: async (url, options = {}) => {
|
||||
requests.push({
|
||||
url,
|
||||
method: options.method,
|
||||
body: options.body ? JSON.parse(options.body) : null,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ address: 'user@mail.example.com' }),
|
||||
};
|
||||
},
|
||||
fetchIcloudHideMyEmail: async () => {
|
||||
throw new Error('should not use icloud generator');
|
||||
},
|
||||
getCloudflareTempEmailAddressFromResponse: (payload) => payload.address,
|
||||
getCloudflareTempEmailConfig: () => ({
|
||||
baseUrl: 'https://temp.example.com',
|
||||
adminAuth: 'admin-secret',
|
||||
customAuth: '',
|
||||
useRandomSubdomain: false,
|
||||
domain: 'mail.example.com',
|
||||
}),
|
||||
getState: async () => ({
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'cloudflare-temp-email',
|
||||
}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
throw new Error('should not allocate mail2925 account');
|
||||
},
|
||||
joinCloudflareTempEmailUrl: (baseUrl, path) => `${baseUrl}${path}`,
|
||||
normalizeCloudflareDomain: () => '',
|
||||
normalizeCloudflareTempEmailAddress: (value) => String(value || '').trim().toLowerCase(),
|
||||
normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
|
||||
isGeneratedAliasProvider: () => false,
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not use duck generator');
|
||||
},
|
||||
setEmailState: async (email) => {
|
||||
savedEmails.push(email);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const email = await helpers.fetchGeneratedEmail({
|
||||
emailGenerator: 'cloudflare-temp-email',
|
||||
}, {
|
||||
generator: 'cloudflare-temp-email',
|
||||
});
|
||||
|
||||
assert.equal(email, 'user@mail.example.com');
|
||||
assert.deepEqual(savedEmails, ['user@mail.example.com']);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].url, 'https://temp.example.com/admin/new_address');
|
||||
assert.equal(requests[0].method, 'POST');
|
||||
assert.deepEqual(requests[0].body, {
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain: false,
|
||||
name: requests[0].body.name,
|
||||
domain: 'mail.example.com',
|
||||
});
|
||||
assert.match(requests[0].body.name, /^[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
test('generated email helper requests random subdomain creation while preserving the returned address', async () => {
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const requests = [];
|
||||
const savedEmails = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
addLog: async () => {},
|
||||
buildGeneratedAliasEmail: () => {
|
||||
throw new Error('should not build managed alias');
|
||||
},
|
||||
buildCloudflareTempEmailHeaders: () => ({ 'x-admin-auth': 'admin-secret' }),
|
||||
CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
|
||||
DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
|
||||
fetch: async (url, options = {}) => {
|
||||
requests.push({
|
||||
url,
|
||||
method: options.method,
|
||||
body: options.body ? JSON.parse(options.body) : null,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ address: 'user@a1b2c3d4.example.com' }),
|
||||
};
|
||||
},
|
||||
fetchIcloudHideMyEmail: async () => {
|
||||
throw new Error('should not use icloud generator');
|
||||
},
|
||||
getCloudflareTempEmailAddressFromResponse: (payload) => payload.address,
|
||||
getCloudflareTempEmailConfig: () => ({
|
||||
baseUrl: 'https://temp.example.com',
|
||||
adminAuth: 'admin-secret',
|
||||
customAuth: '',
|
||||
useRandomSubdomain: true,
|
||||
domain: 'mail.example.com',
|
||||
}),
|
||||
getState: async () => ({
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'cloudflare-temp-email',
|
||||
}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
throw new Error('should not allocate mail2925 account');
|
||||
},
|
||||
joinCloudflareTempEmailUrl: (baseUrl, path) => `${baseUrl}${path}`,
|
||||
normalizeCloudflareDomain: () => '',
|
||||
normalizeCloudflareTempEmailAddress: (value) => String(value || '').trim().toLowerCase(),
|
||||
normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
|
||||
isGeneratedAliasProvider: () => false,
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not use duck generator');
|
||||
},
|
||||
setEmailState: async (email) => {
|
||||
savedEmails.push(email);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const email = await helpers.fetchGeneratedEmail({
|
||||
emailGenerator: 'cloudflare-temp-email',
|
||||
}, {
|
||||
generator: 'cloudflare-temp-email',
|
||||
localPart: 'user',
|
||||
});
|
||||
|
||||
assert.equal(email, 'user@a1b2c3d4.example.com');
|
||||
assert.deepEqual(savedEmails, ['user@a1b2c3d4.example.com']);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].url, 'https://temp.example.com/admin/new_address');
|
||||
assert.equal(requests[0].method, 'POST');
|
||||
assert.deepEqual(requests[0].body, {
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain: true,
|
||||
name: 'user',
|
||||
domain: 'mail.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('generated email helper honors iCloud always-new fetch mode', async () => {
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const icloudOptions = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
addLog: async () => {},
|
||||
buildGeneratedAliasEmail: () => {
|
||||
throw new Error('should not build managed alias');
|
||||
},
|
||||
buildCloudflareTempEmailHeaders: () => ({}),
|
||||
CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
|
||||
DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
|
||||
fetch: async () => ({ ok: true, text: async () => '{}' }),
|
||||
fetchIcloudHideMyEmail: async (options) => {
|
||||
icloudOptions.push(options);
|
||||
return 'fresh@icloud.example.com';
|
||||
},
|
||||
getCloudflareTempEmailAddressFromResponse: () => '',
|
||||
getCloudflareTempEmailConfig: () => ({ baseUrl: '', adminAuth: '', domain: '' }),
|
||||
getState: async () => ({
|
||||
emailGenerator: 'icloud',
|
||||
icloudFetchMode: 'always_new',
|
||||
mailProvider: 'gmail',
|
||||
}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
throw new Error('should not allocate mail2925 account');
|
||||
},
|
||||
joinCloudflareTempEmailUrl: () => '',
|
||||
normalizeCloudflareDomain: () => '',
|
||||
normalizeCloudflareTempEmailAddress: () => '',
|
||||
normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
|
||||
isGeneratedAliasProvider: () => false,
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not use duck generator');
|
||||
},
|
||||
setEmailState: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const email = await helpers.fetchGeneratedEmail({
|
||||
emailGenerator: 'icloud',
|
||||
icloudFetchMode: 'always_new',
|
||||
mailProvider: 'gmail',
|
||||
}, {
|
||||
generator: 'icloud',
|
||||
});
|
||||
|
||||
assert.equal(email, 'fresh@icloud.example.com');
|
||||
assert.deepEqual(icloudOptions, [{ generateNew: true }]);
|
||||
});
|
||||
|
||||
@@ -291,6 +291,152 @@ return {
|
||||
assert.equal(snapshot.buildCalls.length, 1);
|
||||
});
|
||||
|
||||
test('pollLuckmailVerificationCode snapshots existing mails before accepting new LuckMail code', async () => {
|
||||
const bundle = extractFunction('pollLuckmailVerificationCode');
|
||||
|
||||
const factory = new Function(`
|
||||
let currentState = {
|
||||
currentLuckmailPurchase: {
|
||||
id: 7,
|
||||
email_address: 'luck@example.com',
|
||||
token: 'tok-luck',
|
||||
},
|
||||
currentLuckmailMailCursor: null,
|
||||
};
|
||||
const logs = [];
|
||||
const cursorWrites = [];
|
||||
let tokenCodeCalls = 0;
|
||||
|
||||
function getCurrentLuckmailPurchase(state) {
|
||||
return state.currentLuckmailPurchase;
|
||||
}
|
||||
function createLuckmailClient() {
|
||||
return {
|
||||
user: {
|
||||
async getTokenMails() {
|
||||
if (tokenCodeCalls === 0) {
|
||||
return {
|
||||
mails: [
|
||||
{ message_id: 'old-mail', received_at: '2026-04-14 13:31:15', verification_code: '111111' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
mails: [
|
||||
{ message_id: 'new-mail', received_at: '2026-04-14 13:32:05', verification_code: '222222' },
|
||||
{ message_id: 'old-mail', received_at: '2026-04-14 13:31:15', verification_code: '111111' },
|
||||
],
|
||||
};
|
||||
},
|
||||
async getTokenCode() {
|
||||
tokenCodeCalls += 1;
|
||||
return tokenCodeCalls === 1
|
||||
? {
|
||||
has_new_mail: true,
|
||||
verification_code: '111111',
|
||||
mail: { message_id: 'old-mail', received_at: '2026-04-14 13:31:15', verification_code: '111111' },
|
||||
}
|
||||
: {
|
||||
has_new_mail: true,
|
||||
verification_code: '222222',
|
||||
mail: { message_id: 'new-mail', received_at: '2026-04-14 13:32:05', verification_code: '222222' },
|
||||
};
|
||||
},
|
||||
async getTokenMailDetail(_token, messageId) {
|
||||
return { message_id: messageId, received_at: '2026-04-14 13:32:05', verification_code: '222222' };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
async function getState() {
|
||||
return currentState;
|
||||
}
|
||||
async function setLuckmailMailCursorState(cursor) {
|
||||
currentState = { ...currentState, currentLuckmailMailCursor: cursor };
|
||||
cursorWrites.push(cursor);
|
||||
}
|
||||
function normalizeLuckmailMailCursor(cursor) {
|
||||
return {
|
||||
messageId: cursor?.messageId || cursor?.message_id || '',
|
||||
receivedAt: cursor?.receivedAt || cursor?.received_at || '',
|
||||
};
|
||||
}
|
||||
function normalizeLuckmailTimestamp(value) {
|
||||
return Date.parse(String(value || '').replace(' ', 'T') + 'Z') || 0;
|
||||
}
|
||||
function buildLuckmailMailCursor(mail) {
|
||||
return { messageId: mail.message_id || '', receivedAt: mail.received_at || '' };
|
||||
}
|
||||
function buildLuckmailBaselineCursor(mails) {
|
||||
const latest = mails[0] || null;
|
||||
return latest ? buildLuckmailMailCursor(latest) : null;
|
||||
}
|
||||
function isLuckmailMailNewerThanCursor(mail, cursor) {
|
||||
if (!cursor?.messageId && !cursor?.receivedAt) return true;
|
||||
if (mail.message_id === cursor.messageId) return false;
|
||||
return normalizeLuckmailTimestamp(mail.received_at) > normalizeLuckmailTimestamp(cursor.receivedAt);
|
||||
}
|
||||
function pickLuckmailVerificationMail(mails, filters) {
|
||||
const excludeCodes = new Set(filters.excludeCodes || []);
|
||||
for (const mail of mails || []) {
|
||||
if (!mail.verification_code || excludeCodes.has(mail.verification_code)) continue;
|
||||
return { mail, code: mail.verification_code };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function normalizeLuckmailTokenCode(result) {
|
||||
return result;
|
||||
}
|
||||
async function resolveLuckmailVerificationMail(client, token, filters, tokenCodeResult) {
|
||||
if (tokenCodeResult?.mail) {
|
||||
const inline = pickLuckmailVerificationMail([tokenCodeResult.mail], filters);
|
||||
if (inline) return inline;
|
||||
}
|
||||
const mailList = await client.user.getTokenMails(token);
|
||||
return pickLuckmailVerificationMail(mailList.mails, filters);
|
||||
}
|
||||
async function addLog(message, level) {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
function throwIfStopped() {}
|
||||
function isStopError() {
|
||||
return false;
|
||||
}
|
||||
async function sleepWithStop() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
pollLuckmailVerificationCode,
|
||||
snapshot() {
|
||||
return { currentState, cursorWrites, logs, tokenCodeCalls };
|
||||
},
|
||||
};
|
||||
`);
|
||||
|
||||
const api = factory();
|
||||
const result = await api.pollLuckmailVerificationCode(4, await api.snapshot().currentState, {
|
||||
maxAttempts: 2,
|
||||
intervalMs: 1000,
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['code'],
|
||||
excludeCodes: [],
|
||||
});
|
||||
|
||||
assert.equal(result.code, '222222');
|
||||
const snapshot = api.snapshot();
|
||||
assert.deepStrictEqual(snapshot.cursorWrites[0], {
|
||||
messageId: 'old-mail',
|
||||
receivedAt: '2026-04-14 13:31:15',
|
||||
});
|
||||
assert.deepStrictEqual(snapshot.cursorWrites.at(-1), {
|
||||
messageId: 'new-mail',
|
||||
receivedAt: '2026-04-14 13:32:05',
|
||||
});
|
||||
assert.equal(snapshot.logs.some((entry) => /已保存当前邮箱旧邮件快照/.test(entry.message)), true);
|
||||
assert.equal(snapshot.tokenCodeCalls, 2);
|
||||
});
|
||||
|
||||
test('listLuckmailPurchasesByProject only keeps openai purchases', async () => {
|
||||
const bundle = extractFunction('listLuckmailPurchasesByProject');
|
||||
|
||||
|
||||
@@ -266,3 +266,314 @@ test('ensureMail2925MailboxSession logs in when login page is detected and accou
|
||||
assert.equal(readyCalls, 1);
|
||||
assert.equal(result.result.loggedIn, true);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession recovers after login-page navigation reload breaks the old content-script channel', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
let tabUrl = 'https://2925.com/login/';
|
||||
const events = {
|
||||
logs: [],
|
||||
readyCalls: 0,
|
||||
sendCalls: 0,
|
||||
waitCompleteCalls: 0,
|
||||
};
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: tabUrl, status: tabUrl.includes('/#/mailList') ? 'complete' : 'loading' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {
|
||||
events.readyCalls += 1;
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToContentScriptResilient: async () => {
|
||||
events.sendCalls += 1;
|
||||
if (events.sendCalls === 1) {
|
||||
throw new Error('Could not establish connection. Receiving end does not exist.');
|
||||
}
|
||||
return { loggedIn: true, currentView: 'mailbox', mailboxEmail: 'acc1@2925.com' };
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
waitForTabComplete: async () => {
|
||||
events.waitCompleteCalls += 1;
|
||||
tabUrl = 'https://2925.com/#/mailList';
|
||||
return { id: 9, url: tabUrl, status: 'complete' };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(events.sendCalls, 2);
|
||||
assert.equal(events.readyCalls, 2);
|
||||
assert.equal(events.waitCompleteCalls, 1);
|
||||
assert.equal(result.result.loggedIn, true);
|
||||
const combinedLogs = events.logs.map(({ message }) => message).join('\n');
|
||||
assert.match(combinedLogs, /登录提交后页面发生跳转或重载/);
|
||||
assert.match(combinedLogs, /登录跳转恢复后当前标签地址:https:\/\/2925\.com\/#\/mailList/);
|
||||
assert.match(combinedLogs, /页面恢复完成,正在重新确认登录态/);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession relogs with selected account when mailbox page email mismatches and pool is on', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925UseAccountPool: true,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'target@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
const openedUrls = [];
|
||||
const sendPayloads = [];
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: openedUrls.at(-1) || 'https://2925.com/#/mailList' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async (_source, url) => {
|
||||
openedUrls.push(url);
|
||||
return 9;
|
||||
},
|
||||
sendToMailContentScriptResilient: async (_mail, message) => {
|
||||
sendPayloads.push(message.payload);
|
||||
if (sendPayloads.length === 1) {
|
||||
return {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: 'wrong@2925.com',
|
||||
};
|
||||
}
|
||||
return {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: 'target@2925.com',
|
||||
};
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
waitForTabUrlMatch: async () => ({ url: 'https://2925.com/login/' }),
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
expectedMailboxEmail: 'target@2925.com',
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(result.result.loggedIn, true);
|
||||
assert.deepStrictEqual(openedUrls, [
|
||||
'https://2925.com/#/mailList',
|
||||
'https://2925.com/login/',
|
||||
]);
|
||||
assert.equal(sendPayloads.length, 2);
|
||||
assert.equal(sendPayloads[0].allowLoginWhenOnLoginPage, true);
|
||||
assert.equal(sendPayloads[1].forceLogin, true);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession stops when mailbox page email mismatches and pool is off', async () => {
|
||||
let currentState = {
|
||||
autoRunning: true,
|
||||
autoRunPhase: 'running',
|
||||
mail2925UseAccountPool: false,
|
||||
mail2925Accounts: [],
|
||||
currentMail2925AccountId: null,
|
||||
};
|
||||
const stopCalls = [];
|
||||
let sendCalls = 0;
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
requestStop: async (options = {}) => {
|
||||
stopCalls.push(options);
|
||||
},
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
sendCalls += 1;
|
||||
return {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: 'wrong@2925.com',
|
||||
};
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => manager.ensureMail2925MailboxSession({
|
||||
accountId: null,
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: false,
|
||||
expectedMailboxEmail: 'target@2925.com',
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
}),
|
||||
/流程已被用户停止。/
|
||||
);
|
||||
|
||||
assert.equal(sendCalls, 1);
|
||||
assert.equal(stopCalls.length, 1);
|
||||
assert.match(stopCalls[0].logMessage, /与目标账号 target@2925\.com 不一致/);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession does not crash when mailbox page is reused but top email cannot be detected', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925UseAccountPool: false,
|
||||
mail2925Accounts: [],
|
||||
currentMail2925AccountId: null,
|
||||
};
|
||||
let sendCalls = 0;
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
sendCalls += 1;
|
||||
return {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: '',
|
||||
};
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: null,
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: false,
|
||||
expectedMailboxEmail: 'target@2925.com',
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(sendCalls, 1);
|
||||
assert.equal(result.account, null);
|
||||
assert.equal(result.result.usedExistingSession, true);
|
||||
});
|
||||
|
||||
@@ -8,11 +8,13 @@ test('background mail2925 session uses /login/ as relogin entry url', () => {
|
||||
assert.match(source, /const MAIL2925_LOGIN_URL = 'https:\/\/2925\.com\/login\/';/);
|
||||
});
|
||||
|
||||
test('background mail2925 session keeps login message timeout above the 40-second mailbox wait', () => {
|
||||
test('background mail2925 session keeps a long login response timeout and a separate page-recovery window', () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
assert.match(source, /timeoutMs:\s*50000,/);
|
||||
assert.match(source, /responseTimeoutMs:\s*50000,/);
|
||||
assert.match(source, /40 秒内未进入收件箱/);
|
||||
assert.match(source, /const MAIL2925_LOGIN_MESSAGE_RETRY_WINDOW_MS = 15000;/);
|
||||
assert.match(source, /const MAIL2925_LOGIN_RESPONSE_TIMEOUT_MS = 120000;/);
|
||||
assert.match(source, /const MAIL2925_LOGIN_PAGE_RECOVERY_TIMEOUT_MS = 120000;/);
|
||||
assert.match(source, /responseTimeoutMs:\s*MAIL2925_LOGIN_RESPONSE_TIMEOUT_MS,/);
|
||||
assert.match(source, /recoverMail2925LoginPageAfterTransportError/);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession waits 3 seconds before and after opening login page on force relogin', async () => {
|
||||
|
||||
@@ -294,3 +294,46 @@ test('handleMail2925LimitReachedError stops immediately when account pool is off
|
||||
assert.equal(events.stopCalls.length, 1);
|
||||
assert.equal(currentState.currentMail2925AccountId, 'current');
|
||||
});
|
||||
|
||||
test('setCurrentMail2925Account persists currentMail2925AccountId for browser restart restore', async () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
let currentState = {
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: null,
|
||||
};
|
||||
const persistedUpdates = [];
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
setPersistentSettings: async (payload) => {
|
||||
persistedUpdates.push(payload);
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
await manager.setCurrentMail2925Account('acc-1');
|
||||
|
||||
assert.equal(currentState.currentMail2925AccountId, 'acc-1');
|
||||
assert.deepStrictEqual(persistedUpdates, [
|
||||
{ currentMail2925AccountId: 'acc-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ function createRouter(overrides = {}) {
|
||||
notifyCompletions: [],
|
||||
notifyErrors: [],
|
||||
securityBlocks: [],
|
||||
invalidations: [],
|
||||
executedSteps: [],
|
||||
};
|
||||
|
||||
const router = api.createMessageRouter({
|
||||
@@ -41,7 +43,9 @@ function createRouter(overrides = {}) {
|
||||
disableUsedLuckmailPurchases: async () => {},
|
||||
doesStepUseCompletionSignal: () => false,
|
||||
ensureManualInteractionAllowed: async () => ({}),
|
||||
executeStep: async () => {},
|
||||
executeStep: async (step) => {
|
||||
events.executedSteps.push(step);
|
||||
},
|
||||
executeStepViaCompletionSignal: async () => {},
|
||||
exportSettingsBundle: async () => ({}),
|
||||
fetchGeneratedEmail: async () => '',
|
||||
@@ -55,6 +59,7 @@ function createRouter(overrides = {}) {
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getSourceLabel: () => '',
|
||||
getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } },
|
||||
getTabId: overrides.getTabId || (async () => null),
|
||||
getStopRequested: () => false,
|
||||
handleAutoRunLoopUnhandledError: async () => {},
|
||||
handleCloudflareSecurityBlocked: overrides.handleCloudflareSecurityBlocked || (async (error) => {
|
||||
@@ -63,13 +68,16 @@ function createRouter(overrides = {}) {
|
||||
return message.replace(/^CF_SECURITY_BLOCKED::/, '') || message;
|
||||
}),
|
||||
importSettingsBundle: async () => {},
|
||||
invalidateDownstreamAfterStepRestart: async () => {},
|
||||
invalidateDownstreamAfterStepRestart: async (step, options) => {
|
||||
events.invalidations.push({ step, options });
|
||||
},
|
||||
isCloudflareSecurityBlockedError: overrides.isCloudflareSecurityBlockedError || ((error) => /^CF_SECURITY_BLOCKED::/.test(typeof error === 'string' ? error : error?.message || '')),
|
||||
isAutoRunLockedState: () => false,
|
||||
isHotmailProvider: () => false,
|
||||
isLocalhostOAuthCallbackUrl: () => true,
|
||||
isLuckmailProvider: () => false,
|
||||
isStopError: () => false,
|
||||
isTabAlive: overrides.isTabAlive || (async () => false),
|
||||
launchAutoRunTimerPlan: async () => {},
|
||||
listIcloudAliases: async () => [],
|
||||
listLuckmailPurchasesForManagement: async () => [],
|
||||
@@ -143,6 +151,39 @@ test('message router does not overwrite a completed step 3 when step 2 is replay
|
||||
assert.deepStrictEqual(events.stepStatuses, []);
|
||||
});
|
||||
|
||||
test('message router skips steps 3/4/5 when step 2 detects already logged-in session', async () => {
|
||||
const { router, events } = createRouter({
|
||||
state: { stepStatuses: { 3: 'pending', 4: 'completed', 5: 'pending' } },
|
||||
});
|
||||
|
||||
await router.handleStepData(2, {
|
||||
email: 'user@example.com',
|
||||
skipRegistrationFlow: true,
|
||||
skippedPasswordStep: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.emailStates, ['user@example.com']);
|
||||
assert.deepStrictEqual(events.stepStatuses, [
|
||||
{ step: 3, status: 'skipped' },
|
||||
{ step: 5, status: 'skipped' },
|
||||
]);
|
||||
assert.equal(events.logs[0]?.message, '步骤 2:检测到当前已登录会话,已自动跳过步骤 3/4/5,流程将直接进入步骤 6。');
|
||||
});
|
||||
|
||||
test('message router skips step 5 when step 4 reports already logged-in transition', async () => {
|
||||
const { router, events } = createRouter({
|
||||
state: { stepStatuses: { 5: 'pending' } },
|
||||
});
|
||||
|
||||
await router.handleStepData(4, {
|
||||
emailTimestamp: 123,
|
||||
skipProfileStep: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.stepStatuses, [{ step: 5, status: 'skipped' }]);
|
||||
assert.equal(events.logs[0]?.message, '步骤 4:检测到账号已直接进入已登录态,已自动跳过步骤 5。');
|
||||
});
|
||||
|
||||
test('message router finalizes step 3 before marking it completed', async () => {
|
||||
const { router, events } = createRouter();
|
||||
|
||||
@@ -226,3 +267,22 @@ test('message router stops the flow and surfaces cloudflare security block error
|
||||
error: '您已触发Cloudflare 安全防护系统',
|
||||
});
|
||||
});
|
||||
|
||||
test('message router blocks manual step 4 execution when signup page tab is missing', async () => {
|
||||
const { router, events } = createRouter({
|
||||
getTabId: async () => null,
|
||||
isTabAlive: async () => false,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => router.handleMessage({
|
||||
type: 'EXECUTE_STEP',
|
||||
source: 'sidepanel',
|
||||
payload: { step: 4 },
|
||||
}, {}),
|
||||
/手动执行步骤 4 前,请先执行步骤 1 或步骤 2/
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(events.invalidations, []);
|
||||
assert.deepStrictEqual(events.executedSteps, []);
|
||||
});
|
||||
|
||||
@@ -15,3 +15,23 @@ test('navigation utils module exposes a factory', () => {
|
||||
|
||||
assert.equal(typeof api?.createNavigationUtils, 'function');
|
||||
});
|
||||
|
||||
test('navigation utils support codex2api mode and url normalization', () => {
|
||||
const source = fs.readFileSync('background/navigation-utils.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundNavigationUtils;`)(globalScope);
|
||||
const utils = api.createNavigationUtils({
|
||||
DEFAULT_CODEX2API_URL: 'http://localhost:8080/admin/accounts',
|
||||
DEFAULT_SUB2API_URL: 'https://sub.example.com/admin/accounts',
|
||||
normalizeLocalCpaStep9Mode: (value) => value,
|
||||
});
|
||||
|
||||
assert.equal(utils.normalizeCodex2ApiUrl('localhost:8080/admin'), 'http://localhost:8080/admin/accounts');
|
||||
assert.equal(
|
||||
utils.normalizeCodex2ApiUrl('https://codex-admin.example.com/'),
|
||||
'https://codex-admin.example.com/admin/accounts'
|
||||
);
|
||||
assert.equal(utils.getPanelMode({ panelMode: 'codex2api' }), 'codex2api');
|
||||
assert.equal(utils.getPanelModeLabel('codex2api'), 'Codex2API');
|
||||
});
|
||||
|
||||
@@ -21,3 +21,53 @@ test('panel bridge requests oauth url with step 7 log label payload', () => {
|
||||
assert.match(source, /logStep:\s*7/);
|
||||
assert.doesNotMatch(source, /logStep:\s*6/);
|
||||
});
|
||||
|
||||
test('panel bridge can request codex2api oauth url via protocol', async () => {
|
||||
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
assert.equal(url, 'http://localhost:8080/api/admin/oauth/generate-auth-url');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.equal(options.headers['X-Admin-Key'], 'admin-secret');
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
auth_url: 'https://auth.openai.com/authorize?state=oauth-state',
|
||||
session_id: 'session-123',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({});
|
||||
const bridge = api.createPanelBridge({
|
||||
addLog: async () => {},
|
||||
chrome: {},
|
||||
closeConflictingTabsForSource: async () => {},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
getPanelMode: () => 'codex2api',
|
||||
normalizeCodex2ApiUrl: (value) => value ? `http://${value.replace(/^https?:\/\//, '')}`.replace(/\/admin$/, '/admin/accounts') : 'http://localhost:8080/admin/accounts',
|
||||
normalizeSub2ApiUrl: (value) => value,
|
||||
rememberSourceLastUrl: async () => {},
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
waitForTabUrlFamily: async () => null,
|
||||
DEFAULT_SUB2API_GROUP_NAME: 'codex',
|
||||
SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
|
||||
});
|
||||
|
||||
const result = await bridge.requestOAuthUrlFromPanel({
|
||||
panelMode: 'codex2api',
|
||||
codex2apiUrl: 'localhost:8080/admin',
|
||||
codex2apiAdminKey: 'admin-secret',
|
||||
}, { logLabel: '步骤 7' });
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state',
|
||||
codex2apiSessionId: 'session-123',
|
||||
codex2apiOAuthState: 'oauth-state',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
|
||||
test('platform verify module supports codex2api protocol callback exchange', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
assert.equal(url, 'http://localhost:8080/api/admin/oauth/exchange-code');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.equal(options.headers['X-Admin-Key'], 'admin-secret');
|
||||
assert.deepStrictEqual(JSON.parse(options.body), {
|
||||
session_id: 'session-123',
|
||||
code: 'callback-code',
|
||||
state: 'oauth-state',
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: 'OAuth 账号 flow@example.com 添加成功',
|
||||
id: 42,
|
||||
email: 'flow@example.com',
|
||||
plan_type: 'pro',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
const completed = [];
|
||||
const logs = [];
|
||||
const executor = api.createStep10Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
chrome: {},
|
||||
closeConflictingTabsForSource: async () => {},
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
completed.push({ step, payload });
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
getPanelMode: () => 'codex2api',
|
||||
getTabId: async () => 0,
|
||||
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
|
||||
isTabAlive: async () => false,
|
||||
normalizeCodex2ApiUrl: () => 'http://localhost:8080/admin/accounts',
|
||||
normalizeSub2ApiUrl: (value) => value,
|
||||
rememberSourceLastUrl: async () => {},
|
||||
reuseOrCreateTab: async () => 0,
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
shouldBypassStep9ForLocalCpa: () => false,
|
||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
||||
});
|
||||
|
||||
await executor.executeStep10({
|
||||
panelMode: 'codex2api',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
codex2apiUrl: 'http://localhost:8080/admin/accounts',
|
||||
codex2apiAdminKey: 'admin-secret',
|
||||
codex2apiSessionId: 'session-123',
|
||||
codex2apiOAuthState: 'oauth-state',
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(logs, [
|
||||
{ message: '步骤 10:正在向 Codex2API 提交回调并创建账号...', level: 'info' },
|
||||
{ message: '步骤 10:OAuth 账号 flow@example.com 添加成功', level: 'ok' },
|
||||
]);
|
||||
assert.deepStrictEqual(completed, [
|
||||
{
|
||||
step: 10,
|
||||
payload: {
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
verifiedStatus: 'OAuth 账号 flow@example.com 添加成功',
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -84,6 +84,55 @@ test('step 2 keeps password flow when landing on password page', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 2 falls back to already-logged-in branch when auth entry recovery fails on chatgpt home', async () => {
|
||||
const completedPayloads = [];
|
||||
const logs = [];
|
||||
|
||||
const executor = step2Api.createStep2Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
get: async () => ({ url: 'https://chatgpt.com/' }),
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
completedPayloads.push({ step, payload });
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
ensureSignupAuthEntryPageReady: async () => {
|
||||
throw new Error('当前页面没有可用的注册入口,也不在邮箱/密码页。URL: https://chatgpt.com/');
|
||||
},
|
||||
ensureSignupEntryPageReady: async () => ({ tabId: 13 }),
|
||||
ensureSignupPostEmailPageReadyInTab: async () => ({
|
||||
state: 'password_page',
|
||||
url: 'https://auth.openai.com/create-account/password',
|
||||
}),
|
||||
getTabId: async () => 13,
|
||||
isTabAlive: async () => true,
|
||||
resolveSignupEmailForFlow: async () => 'user@example.com',
|
||||
sendToContentScriptResilient: async () => ({ submitted: true }),
|
||||
SIGNUP_PAGE_INJECT_FILES: [],
|
||||
});
|
||||
|
||||
await executor.executeStep2({ email: 'user@example.com' });
|
||||
|
||||
assert.equal(completedPayloads.length, 1);
|
||||
assert.deepStrictEqual(completedPayloads[0], {
|
||||
step: 2,
|
||||
payload: {
|
||||
email: 'user@example.com',
|
||||
nextSignupState: 'already_logged_in_home',
|
||||
nextSignupUrl: 'https://chatgpt.com/',
|
||||
skippedPasswordStep: true,
|
||||
skipRegistrationFlow: true,
|
||||
},
|
||||
});
|
||||
assert.ok(logs.some((item) => /已跳过注册链路/.test(item.message)));
|
||||
});
|
||||
|
||||
test('signup flow helper recognizes email verification page as post-email landing page', async () => {
|
||||
let ensureCalls = 0;
|
||||
let passwordReadyChecks = 0;
|
||||
@@ -175,8 +224,12 @@ test('signup flow helper reuses existing managed alias email when it is still co
|
||||
test('signup flow helper finalizes step 3 submit by reusing signup verification preparation', async () => {
|
||||
let ensureCalls = 0;
|
||||
const messages = [];
|
||||
const logs = [];
|
||||
|
||||
const helpers = signupFlowApi.createSignupFlowHelpers({
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
buildGeneratedAliasEmail: () => '',
|
||||
chrome: { tabs: { get: async () => ({ id: 31, url: 'https://auth.openai.com/create-account/password' }) } },
|
||||
ensureContentScriptReadyOnTab: async (...args) => {
|
||||
@@ -188,6 +241,7 @@ test('signup flow helper finalizes step 3 submit by reusing signup verification
|
||||
isGeneratedAliasProvider: () => false,
|
||||
isReusableGeneratedAliasEmail: () => false,
|
||||
isHotmailProvider: () => false,
|
||||
isRetryableContentScriptTransportError: () => false,
|
||||
isLuckmailProvider: () => false,
|
||||
isSignupEmailVerificationPageUrl: () => false,
|
||||
isSignupPasswordPageUrl: () => true,
|
||||
@@ -206,6 +260,7 @@ test('signup flow helper finalizes step 3 submit by reusing signup verification
|
||||
|
||||
assert.deepStrictEqual(result, { ready: true, retried: 1 });
|
||||
assert.equal(ensureCalls, 1);
|
||||
assert.deepStrictEqual(logs, []);
|
||||
assert.deepStrictEqual(messages.find((item) => item.type === 'send')?.message, {
|
||||
type: 'PREPARE_SIGNUP_VERIFICATION',
|
||||
step: 3,
|
||||
@@ -217,3 +272,45 @@ test('signup flow helper finalizes step 3 submit by reusing signup verification
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('signup flow helper rewrites retryable step 3 finalize transport timeout into a Chinese error', async () => {
|
||||
const logs = [];
|
||||
|
||||
const helpers = signupFlowApi.createSignupFlowHelpers({
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
buildGeneratedAliasEmail: () => '',
|
||||
chrome: { tabs: { get: async () => ({ id: 31, url: 'https://auth.openai.com/create-account/password' }) } },
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
ensureHotmailAccountForFlow: async () => ({}),
|
||||
ensureLuckmailPurchaseForFlow: async () => ({}),
|
||||
isGeneratedAliasProvider: () => false,
|
||||
isReusableGeneratedAliasEmail: () => false,
|
||||
isHotmailProvider: () => false,
|
||||
isRetryableContentScriptTransportError: (error) => /did not respond in 45s/i.test(error?.message || String(error || '')),
|
||||
isLuckmailProvider: () => false,
|
||||
isSignupEmailVerificationPageUrl: () => false,
|
||||
isSignupPasswordPageUrl: () => true,
|
||||
reuseOrCreateTab: async () => 31,
|
||||
sendToContentScriptResilient: async () => {
|
||||
throw new Error('Content script on signup-page did not respond in 45s. Try refreshing the tab and retry.');
|
||||
},
|
||||
setEmailState: async () => {},
|
||||
SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
|
||||
SIGNUP_PAGE_INJECT_FILES: ['content/utils.js', 'content/signup-page.js'],
|
||||
waitForTabUrlMatch: async () => null,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => helpers.finalizeSignupPasswordSubmitInTab(31, 'Secret123!', 3),
|
||||
/步骤 3:认证页在提交后切换过程中页面通信超时,未能重新就绪,暂时无法确认是否进入下一页面。请重试当前轮。/
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(logs, [
|
||||
{
|
||||
message: '步骤 3:认证页在提交后切换过程中页面通信超时,未能重新就绪,暂时无法确认是否进入下一页面。请重试当前轮。',
|
||||
level: 'warn',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -119,3 +119,49 @@ test('step 4 does not request a fresh code first for Cloudflare temp mail', asyn
|
||||
assert.equal(capturedOptions.requestFreshCodeFirst, false);
|
||||
assert.equal(capturedOptions.resendIntervalMs, 25000);
|
||||
});
|
||||
|
||||
test('step 4 checks iCloud session before polling iCloud mailbox', async () => {
|
||||
let icloudChecks = 0;
|
||||
let resolved = false;
|
||||
|
||||
const executor = api.createStep4Executor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureIcloudMailSession: async () => {
|
||||
icloudChecks += 1;
|
||||
},
|
||||
ensureMail2925MailboxSession: async () => {},
|
||||
getMailConfig: () => ({
|
||||
source: 'icloud-mail',
|
||||
url: 'https://www.icloud.com/mail/',
|
||||
label: 'iCloud 邮箱',
|
||||
}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
resolveVerificationStep: async () => {
|
||||
resolved = true;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await executor.executeStep4({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
assert.equal(icloudChecks, 1);
|
||||
assert.equal(resolved, true);
|
||||
});
|
||||
|
||||
@@ -177,3 +177,88 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 7 stops immediately when management secret is missing', 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 = {
|
||||
refreshCalls: 0,
|
||||
sendCalls: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep7Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||||
refreshOAuthUrlBeforeStep6: async () => {
|
||||
events.refreshCalls += 1;
|
||||
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async () => {
|
||||
events.sendCalls += 1;
|
||||
return { step6Outcome: 'success' };
|
||||
},
|
||||
STEP6_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
|
||||
/管理密钥/
|
||||
);
|
||||
|
||||
assert.equal(events.refreshCalls, 1);
|
||||
assert.equal(events.sendCalls, 0);
|
||||
assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message)));
|
||||
assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message)));
|
||||
});
|
||||
|
||||
test('step 7 stops immediately when management secret is invalid', 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 = {
|
||||
refreshCalls: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep7Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||||
refreshOAuthUrlBeforeStep6: async () => {
|
||||
events.refreshCalls += 1;
|
||||
throw new Error('Codex2API 请求失败(HTTP 401)。X-Admin-Key 无效或未授权。');
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async () => ({ step6Outcome: 'success' }),
|
||||
STEP6_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
|
||||
/401|未授权|无效/
|
||||
);
|
||||
|
||||
assert.equal(events.refreshCalls, 1);
|
||||
assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message)));
|
||||
assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message)));
|
||||
});
|
||||
|
||||
@@ -92,6 +92,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
test('step 8 uses a fixed 10-minute lookback window and disables resend interval for 2925 mailbox polling', async () => {
|
||||
let capturedOptions = null;
|
||||
let ensureCalls = 0;
|
||||
let ensureOptions = null;
|
||||
const tabUpdates = [];
|
||||
const tabReuses = [];
|
||||
const realDateNow = Date.now;
|
||||
@@ -108,8 +109,9 @@ test('step 8 uses a fixed 10-minute lookback window and disables resend interval
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureMail2925MailboxSession: async () => {
|
||||
ensureMail2925MailboxSession: async (options) => {
|
||||
ensureCalls += 1;
|
||||
ensureOptions = options;
|
||||
},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
|
||||
rerunStep7ForStep8Recovery: async () => {},
|
||||
@@ -122,7 +124,15 @@ test('step 8 uses a fixed 10-minute lookback window and disables resend interval
|
||||
url: 'https://2925.com',
|
||||
navigateOnReuse: false,
|
||||
}),
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
getState: async () => ({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
mail2925UseAccountPool: true,
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
mail2925Accounts: [
|
||||
{ id: 'acc-1', email: 'pool-user@2925.com' },
|
||||
],
|
||||
}),
|
||||
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
@@ -148,16 +158,26 @@ test('step 8 uses a fixed 10-minute lookback window and disables resend interval
|
||||
password: 'secret',
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
mail2925UseAccountPool: true,
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
mail2925Accounts: [
|
||||
{ id: 'acc-1', email: 'pool-user@2925.com' },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
|
||||
assert.equal(ensureCalls, 0);
|
||||
assert.equal(ensureCalls, 1);
|
||||
assert.deepStrictEqual(ensureOptions, {
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
expectedMailboxEmail: 'pool-user@2925.com',
|
||||
actionLabel: 'Step 8: ensure 2925 mailbox session',
|
||||
});
|
||||
assert.deepStrictEqual(tabReuses, []);
|
||||
assert.deepStrictEqual(tabUpdates, [
|
||||
{ tabId: 1, payload: { active: true } },
|
||||
{ tabId: 2, payload: { active: true } },
|
||||
]);
|
||||
assert.equal(capturedOptions.filterAfterTimestamp, 300000);
|
||||
assert.equal(capturedOptions.resendIntervalMs, 0);
|
||||
@@ -276,3 +296,57 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone',
|
||||
assert.equal(calls.rerunStep7, 0);
|
||||
assert.ok(!calls.logs.some(({ message }) => /准备从步骤 7 重新开始/.test(message)));
|
||||
});
|
||||
|
||||
test('step 8 checks iCloud session before polling iCloud mailbox', async () => {
|
||||
let icloudChecks = 0;
|
||||
let resolved = false;
|
||||
|
||||
const executor = api.createStep8Executor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureIcloudMailSession: async () => {
|
||||
icloudChecks += 1;
|
||||
},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: '' }),
|
||||
rerunStep7ForStep8Recovery: async () => {},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
getMailConfig: () => ({
|
||||
source: 'icloud-mail',
|
||||
url: 'https://www.icloud.com/mail/',
|
||||
label: 'iCloud 邮箱',
|
||||
navigateOnReuse: true,
|
||||
}),
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
isVerificationMailPollingError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
resolveVerificationStep: async () => {
|
||||
resolved = true;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await executor.executeStep8({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
});
|
||||
|
||||
assert.equal(icloudChecks, 1);
|
||||
assert.equal(resolved, true);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,41 @@ test('tab runtime module exposes a factory', () => {
|
||||
assert.equal(typeof api?.createTabRuntime, 'function');
|
||||
});
|
||||
|
||||
test('tab runtime caps per-attempt response timeout to the remaining resilient timeout budget', () => {
|
||||
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
|
||||
|
||||
const runtime = api.createTabRuntime({
|
||||
LOG_PREFIX: '[test]',
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 1, url: 'https://example.com', status: 'complete' }),
|
||||
query: async () => [],
|
||||
},
|
||||
},
|
||||
getSourceLabel: (source) => source || 'unknown',
|
||||
getState: async () => ({ tabRegistry: {}, sourceLastUrls: {} }),
|
||||
matchesSourceUrlFamily: () => false,
|
||||
normalizeLocalCpaStep9Mode: () => 'submit',
|
||||
parseUrlSafely: () => null,
|
||||
registerTab: async () => {},
|
||||
setState: async () => {},
|
||||
shouldBypassStep9ForLocalCpa: () => false,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.resolveResponseTimeoutMs({ type: 'PREPARE_SIGNUP_VERIFICATION' }, undefined, 30000),
|
||||
30000
|
||||
);
|
||||
assert.equal(
|
||||
runtime.resolveResponseTimeoutMs({ type: 'PREPARE_SIGNUP_VERIFICATION' }, 12000, 5000),
|
||||
5000
|
||||
);
|
||||
});
|
||||
|
||||
test('tab runtime waitForTabComplete waits until tab status becomes complete', async () => {
|
||||
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/mail-163.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);
|
||||
}
|
||||
|
||||
test('findMailItems falls back to visible aria-label mail rows when legacy selector is missing', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('isVisibleNode'),
|
||||
extractFunction('isLikelyMailItemNode'),
|
||||
extractFunction('findMailItems'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const mailRow = {
|
||||
hidden: false,
|
||||
textContent: 'Your temporary ChatGPT verification code 911113',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'Your temporary ChatGPT verification code 911113 发件人 OpenAI';
|
||||
return '';
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 600, height: 48 };
|
||||
},
|
||||
matches() {
|
||||
return false;
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'div[sign="letter"]') return [];
|
||||
if (selector === '[role="option"][aria-label]') return [mailRow];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const window = {
|
||||
getComputedStyle() {
|
||||
return { display: 'block', visibility: 'visible' };
|
||||
},
|
||||
};
|
||||
|
||||
${bundle}
|
||||
|
||||
return { findMailItems };
|
||||
`)();
|
||||
|
||||
const rows = api.findMailItems();
|
||||
assert.equal(rows.length, 1);
|
||||
});
|
||||
|
||||
test('getMailTimestamp parses visible hh:mm text even when no title attribute exists', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('parseMail163Timestamp'),
|
||||
extractFunction('isLikelyMailTimestampText'),
|
||||
extractFunction('collectMailTimestampCandidates'),
|
||||
extractFunction('getMailTimestamp'),
|
||||
].join('\n');
|
||||
|
||||
const timestamp = new Function(`
|
||||
const item = {
|
||||
getAttribute() {
|
||||
return '';
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.e00, [title], [aria-label], time, [class*="time"], [class*="date"]') {
|
||||
return [];
|
||||
}
|
||||
if (selector === 'span, div, td, strong, b') {
|
||||
return [
|
||||
{
|
||||
textContent: '22:22',
|
||||
getAttribute() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
${bundle}
|
||||
|
||||
return getMailTimestamp(item);
|
||||
`)();
|
||||
|
||||
const now = new Date();
|
||||
const expected = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 22, 0, 0).getTime();
|
||||
assert.equal(timestamp, expected);
|
||||
});
|
||||
|
||||
test('readOpenedMailText prefers opened body content that contains the verification code', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('collectOpenedMailTextCandidates'),
|
||||
extractFunction('selectOpenedMailTextCandidate'),
|
||||
extractFunction('readOpenedMailText'),
|
||||
extractFunction('extractVerificationCode'),
|
||||
].join('\n');
|
||||
|
||||
const text = new Function(`
|
||||
const item = {
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'iframe') {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ innerText: 'Your temporary ChatGPT login code', textContent: 'Your temporary ChatGPT login code' },
|
||||
{ innerText: 'Enter this temporary verification code to continue: 214203', textContent: 'Enter this temporary verification code to continue: 214203' },
|
||||
];
|
||||
},
|
||||
body: {
|
||||
innerText: 'fallback body',
|
||||
textContent: 'fallback body',
|
||||
},
|
||||
};
|
||||
|
||||
${bundle}
|
||||
|
||||
return readOpenedMailText(item);
|
||||
`)();
|
||||
|
||||
assert.match(text, /214203/);
|
||||
});
|
||||
|
||||
test('openMailAndGetMessageText reads opened body text and returns to inbox', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('collectOpenedMailTextCandidates'),
|
||||
extractFunction('selectOpenedMailTextCandidate'),
|
||||
extractFunction('readOpenedMailText'),
|
||||
extractFunction('returnToInbox'),
|
||||
extractFunction('openMailAndGetMessageText'),
|
||||
extractFunction('extractVerificationCode'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let inInbox = true;
|
||||
let clickCount = 0;
|
||||
const mailItem = {
|
||||
click() {
|
||||
clickCount += 1;
|
||||
inInbox = false;
|
||||
},
|
||||
};
|
||||
const inboxLink = {
|
||||
click() {
|
||||
inInbox = true;
|
||||
},
|
||||
};
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
if (selector === '.nui-tree-item-text[title="收件箱"], [title="收件箱"]') return inboxLink;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'iframe') {
|
||||
return [];
|
||||
}
|
||||
return inInbox ? [] : [{ innerText: 'Enter this temporary verification code to continue: 214203', textContent: 'Enter this temporary verification code to continue: 214203' }];
|
||||
},
|
||||
body: {
|
||||
innerText: '',
|
||||
textContent: '',
|
||||
},
|
||||
};
|
||||
|
||||
function findMailItems() {
|
||||
return inInbox ? [mailItem] : [];
|
||||
}
|
||||
|
||||
async function sleep() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
openMailAndGetMessageText,
|
||||
getClickCount: () => clickCount,
|
||||
isInInbox: () => inInbox,
|
||||
mailItem,
|
||||
};
|
||||
`)();
|
||||
|
||||
const text = await api.openMailAndGetMessageText(api.mailItem);
|
||||
assert.match(text, /214203/);
|
||||
assert.equal(api.getClickCount(), 1);
|
||||
assert.equal(api.isInInbox(), true);
|
||||
});
|
||||
|
||||
test('openMailAndGetMessageText ignores stale pre-open text that contains an old code', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('collectOpenedMailTextCandidates'),
|
||||
extractFunction('selectOpenedMailTextCandidate'),
|
||||
extractFunction('readOpenedMailText'),
|
||||
extractFunction('returnToInbox'),
|
||||
extractFunction('openMailAndGetMessageText'),
|
||||
extractFunction('extractVerificationCode'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let stage = 'before';
|
||||
const oldText = 'OpenAI Your temporary ChatGPT login code. Ignore this old code 111111. '.repeat(10);
|
||||
const newText = 'OpenAI Your temporary ChatGPT login code. Your new code is 222222.';
|
||||
const mailItem = {
|
||||
click() {
|
||||
stage = 'after';
|
||||
},
|
||||
};
|
||||
const inboxLink = {
|
||||
click() {
|
||||
stage = 'done';
|
||||
},
|
||||
};
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
if (selector === '.nui-tree-item-text[title="收件箱"], [title="收件箱"]') return inboxLink;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'iframe') {
|
||||
return [];
|
||||
}
|
||||
if (stage === 'before') {
|
||||
return [{ innerText: oldText, textContent: oldText }];
|
||||
}
|
||||
if (stage === 'after') {
|
||||
return [
|
||||
{ innerText: oldText, textContent: oldText },
|
||||
{ innerText: newText, textContent: newText },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
body: {
|
||||
innerText: '',
|
||||
textContent: '',
|
||||
},
|
||||
};
|
||||
|
||||
function findMailItems() {
|
||||
return stage === 'done' ? [mailItem] : [];
|
||||
}
|
||||
|
||||
async function sleep() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { openMailAndGetMessageText, mailItem };
|
||||
`)();
|
||||
|
||||
const text = await api.openMailAndGetMessageText(api.mailItem);
|
||||
assert.match(text, /222222/);
|
||||
assert.doesNotMatch(text, /111111/);
|
||||
});
|
||||
|
||||
test('handlePollEmail ignores same-minute old snapshot mail before fallback', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let currentItems = [{ id: 'old-mail' }];
|
||||
const seenCodes = new Set();
|
||||
|
||||
function findMailItems() {
|
||||
return currentItems;
|
||||
}
|
||||
|
||||
function getCurrentMailIds(items = []) {
|
||||
return new Set((items.length ? items : currentItems).map((item) => item.id));
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getMailTimestamp() {
|
||||
return new Date(2026, 3, 22, 22, 22, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT verification code';
|
||||
}
|
||||
|
||||
function getMailRowText() {
|
||||
return 'Your temporary ChatGPT verification code 911113 发件人 OpenAI';
|
||||
}
|
||||
|
||||
function extractVerificationCode() {
|
||||
return '911113';
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return { click() {} };
|
||||
}
|
||||
async function refreshInbox() {}
|
||||
async function sleep() {}
|
||||
function log() {}
|
||||
function persistSeenCodes() {}
|
||||
function scheduleEmailCleanup() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { handlePollEmail };
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.handlePollEmail(4, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: new Date(2026, 3, 22, 22, 22, 40, 0).getTime(),
|
||||
}),
|
||||
/未在 163 邮箱中找到新的匹配邮件/
|
||||
);
|
||||
});
|
||||
|
||||
test('handlePollEmail accepts a new same-minute mail that appears after the snapshot', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const oldMail = {
|
||||
id: 'old-mail',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'Old verification mail 111111 发件人 OpenAI';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const newMail = {
|
||||
id: 'new-mail',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'Your temporary ChatGPT verification code 654321 发件人 OpenAI';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
let refreshCount = 0;
|
||||
let currentItems = [oldMail];
|
||||
const seenCodes = new Set();
|
||||
|
||||
function findMailItems() {
|
||||
return currentItems;
|
||||
}
|
||||
|
||||
function getCurrentMailIds(items = []) {
|
||||
return new Set((items.length ? items : currentItems).map((item) => item.id));
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getMailTimestamp() {
|
||||
return new Date(2026, 3, 22, 22, 22, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
function getMailSubjectText(item) {
|
||||
return item.id === 'new-mail' ? 'Your temporary ChatGPT verification code' : 'Old verification mail';
|
||||
}
|
||||
|
||||
function getMailRowText(item) {
|
||||
return item.id === 'new-mail'
|
||||
? 'Your temporary ChatGPT verification code 654321 发件人 OpenAI'
|
||||
: 'Old verification mail 111111 发件人 OpenAI';
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const match = String(text || '').match(/(\\d{6})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return { click() {} };
|
||||
}
|
||||
async function refreshInbox() {
|
||||
refreshCount += 1;
|
||||
if (refreshCount >= 1) {
|
||||
currentItems = [oldMail, newMail];
|
||||
}
|
||||
}
|
||||
async function sleep() {}
|
||||
function log() {}
|
||||
function persistSeenCodes() {}
|
||||
function scheduleEmailCleanup() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { handlePollEmail };
|
||||
`)();
|
||||
|
||||
const result = await api.handlePollEmail(4, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification'],
|
||||
maxAttempts: 2,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: new Date(2026, 3, 22, 22, 22, 40, 0).getTime(),
|
||||
});
|
||||
|
||||
assert.equal(result.code, '654321');
|
||||
assert.equal(result.mailId, 'new-mail');
|
||||
});
|
||||
|
||||
test('handlePollEmail falls back to row text when the subject node is missing', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const matchingMail = {
|
||||
id: 'mail-1',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'OpenAI Your temporary ChatGPT verification code 123456';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const seenCodes = new Set();
|
||||
|
||||
function findMailItems() {
|
||||
return [matchingMail];
|
||||
}
|
||||
|
||||
function getCurrentMailIds() {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getMailTimestamp() {
|
||||
return new Date(2026, 3, 22, 22, 22, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMailSubjectText() {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMailRowText() {
|
||||
return 'OpenAI Your temporary ChatGPT verification code 123456';
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const match = String(text || '').match(/(\\d{6})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return { click() {} };
|
||||
}
|
||||
async function refreshInbox() {}
|
||||
async function sleep() {}
|
||||
function log() {}
|
||||
function persistSeenCodes() {}
|
||||
function scheduleEmailCleanup() {}
|
||||
async function openMailAndGetMessageText() {
|
||||
return '';
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { handlePollEmail };
|
||||
`)();
|
||||
|
||||
const result = await api.handlePollEmail(8, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.code, '123456');
|
||||
assert.equal(result.mailId, 'mail-1');
|
||||
});
|
||||
|
||||
test('handlePollEmail opens matching mail body when preview has no code', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const matchingMail = {
|
||||
id: 'mail-body-1',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'OpenAI Your temporary ChatGPT login code';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const seenCodes = new Set();
|
||||
let openedCount = 0;
|
||||
|
||||
function findMailItems() {
|
||||
return [matchingMail];
|
||||
}
|
||||
|
||||
function getCurrentMailIds() {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getMailTimestamp() {
|
||||
return new Date(2026, 3, 22, 22, 49, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function getMailRowText() {
|
||||
return 'OpenAI Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const match = String(text || '').match(/(\\d{6})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return { click() {} };
|
||||
}
|
||||
async function refreshInbox() {}
|
||||
async function sleep() {}
|
||||
function log() {}
|
||||
function persistSeenCodes() {}
|
||||
function scheduleEmailCleanup() {}
|
||||
async function openMailAndGetMessageText() {
|
||||
openedCount += 1;
|
||||
return 'Enter this temporary verification code to continue: 214203';
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { handlePollEmail, getOpenedCount: () => openedCount };
|
||||
`)();
|
||||
|
||||
const result = await api.handlePollEmail(8, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification', 'login'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.code, '214203');
|
||||
assert.equal(result.mailId, 'mail-body-1');
|
||||
assert.equal(api.getOpenedCount(), 1);
|
||||
});
|
||||
@@ -12,6 +12,69 @@ test('ensureMail2925Session waits 1 second after filling credentials before clic
|
||||
assert.match(source, /fillInput\(passwordInput,\s*password\);[\s\S]*?await sleep\(200\);[\s\S]*?await sleep\(1000\);[\s\S]*?simulateClick\(loginButton\);/);
|
||||
});
|
||||
|
||||
test('detectMail2925ViewState treats top mailbox email as mailbox view', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeNodeText'),
|
||||
extractFunction('isVisibleNode'),
|
||||
extractFunction('isMailItemNode'),
|
||||
extractFunction('resolveActionTarget'),
|
||||
extractFunction('findMailItems'),
|
||||
extractFunction('extractEmails'),
|
||||
extractFunction('getMail2925DisplayedMailboxEmail'),
|
||||
extractFunction('detectMail2925ViewState'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const MAIL_ITEM_SELECTORS = ['.mail-item'];
|
||||
const MAIL_ITEM_SELECTOR_GROUP = '.mail-item';
|
||||
const MAIL2925_REMEMBER_LOGIN_PATTERNS = [];
|
||||
const MAIL2925_AGREEMENT_PATTERNS = [];
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.mail-item') return [];
|
||||
if (selector === 'body *') return [headerEmail, wrongHeader];
|
||||
if (selector === '.right-header' || selector.includes('right-header')) return [headerEmail];
|
||||
if (selector.includes('[class*="user"]')) return [];
|
||||
return [];
|
||||
},
|
||||
body: {
|
||||
innerText: 'QLHazycoder qlhazycoder@2925.com tm1.openai.com@foo.example',
|
||||
textContent: 'QLHazycoder qlhazycoder@2925.com tm1.openai.com@foo.example',
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
innerHeight: 900,
|
||||
getComputedStyle() {
|
||||
return { display: 'block', visibility: 'visible' };
|
||||
},
|
||||
};
|
||||
const headerEmail = {
|
||||
hidden: false,
|
||||
textContent: 'qlhazycoder@2925.com',
|
||||
innerText: 'qlhazycoder@2925.com',
|
||||
getBoundingClientRect() { return { top: 40, left: 400, width: 120, height: 20 }; },
|
||||
closest() { return null; },
|
||||
};
|
||||
const wrongHeader = {
|
||||
hidden: false,
|
||||
textContent: 'tm1.openai.com@foo.example',
|
||||
innerText: 'tm1.openai.com@foo.example',
|
||||
getBoundingClientRect() { return { top: 48, left: 430, width: 150, height: 20 }; },
|
||||
closest() { return null; },
|
||||
};
|
||||
function detectMail2925LimitMessage() { return ''; }
|
||||
function findMail2925LoginPasswordInput() { return null; }
|
||||
function findMail2925LoginEmailInput() { return null; }
|
||||
function getPageTextSample() { return 'qlhazycoder@2925.com'; }
|
||||
${bundle}
|
||||
return { detectMail2925ViewState };
|
||||
`)();
|
||||
|
||||
const state = api.detectMail2925ViewState();
|
||||
assert.equal(state.view, 'mailbox');
|
||||
assert.equal(state.mailboxEmail, 'qlhazycoder@2925.com');
|
||||
});
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
|
||||
@@ -48,7 +48,7 @@ function extractFunction(name) {
|
||||
return sidepanelSource.slice(start, end);
|
||||
}
|
||||
|
||||
function createApi({ refreshImpl } = {}) {
|
||||
function createApi({ refreshImpl, confirmImpl, dismissImpl } = {}) {
|
||||
const bundle = extractFunction('startAutoRunFromCurrentSettings');
|
||||
|
||||
return new Function(`
|
||||
@@ -90,6 +90,14 @@ async function refreshContributionContentHint() {
|
||||
events.push({ type: 'refresh' });
|
||||
${refreshImpl ? 'return (' + refreshImpl + ')();' : 'return null;'}
|
||||
}
|
||||
async function maybeConfirmContributionUpdateBeforeAutoRun(snapshot) {
|
||||
events.push({ type: 'confirm-check', snapshot });
|
||||
${confirmImpl ? 'return (' + confirmImpl + ')(snapshot);' : 'return true;'}
|
||||
}
|
||||
function dismissContributionUpdateHint() {
|
||||
events.push({ type: 'dismiss-hint' });
|
||||
${dismissImpl ? '(' + dismissImpl + ')();' : ''}
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
startAutoRunFromCurrentSettings,
|
||||
@@ -108,9 +116,9 @@ test('startAutoRunFromCurrentSettings refreshes contribution content hint before
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(
|
||||
api.getEvents().map((entry) => entry.type),
|
||||
['refresh', 'send']
|
||||
['refresh', 'confirm-check', 'send']
|
||||
);
|
||||
assert.equal(api.getEvents()[1].message.type, 'AUTO_RUN');
|
||||
assert.equal(api.getEvents()[2].message.type, 'AUTO_RUN');
|
||||
});
|
||||
|
||||
test('startAutoRunFromCurrentSettings continues auto run when contribution content refresh fails', async () => {
|
||||
@@ -124,8 +132,26 @@ test('startAutoRunFromCurrentSettings continues auto run when contribution conte
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(
|
||||
events.map((entry) => entry.type),
|
||||
['refresh', 'warn', 'send']
|
||||
['refresh', 'warn', 'confirm-check', 'send']
|
||||
);
|
||||
assert.match(String(events[1].args[0]), /Failed to refresh contribution content hint before auto run/);
|
||||
assert.equal(events[2].message.type, 'AUTO_RUN');
|
||||
assert.equal(events[3].message.type, 'AUTO_RUN');
|
||||
});
|
||||
|
||||
test('startAutoRunFromCurrentSettings aborts when contribution update confirmation is declined', async () => {
|
||||
const api = createApi({
|
||||
refreshImpl: `async () => ({
|
||||
promptVersion: 'questionnaire:2026-04-23T00:00:00Z',
|
||||
items: [{ slug: 'questionnaire', isVisible: true }],
|
||||
})`,
|
||||
confirmImpl: 'async () => false',
|
||||
});
|
||||
|
||||
const result = await api.startAutoRunFromCurrentSettings();
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(
|
||||
api.getEvents().map((entry) => entry.type),
|
||||
['refresh', 'confirm-check']
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
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);
|
||||
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 createRow(initialDisplay = 'none') {
|
||||
return {
|
||||
style: { display: initialDisplay },
|
||||
};
|
||||
}
|
||||
|
||||
test('sidepanel html places cloudflare temp email controls in a standalone section', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
assert.match(html, /id="cloudflare-temp-email-section"/);
|
||||
assert.match(html, /id="btn-cloudflare-temp-email-usage-guide"/);
|
||||
assert.match(html, /id="btn-cloudflare-temp-email-github"/);
|
||||
assert.match(html, /id="row-temp-email-random-subdomain-toggle"/);
|
||||
assert.match(html, /id="input-temp-email-use-random-subdomain"/);
|
||||
assert.doesNotMatch(html, /id="row-temp-email-random-subdomain-domain"/);
|
||||
});
|
||||
|
||||
test('sidepanel modal message preserves line breaks and supports inline links', () => {
|
||||
const css = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
|
||||
assert.match(css, /\.modal-message\s*\{[\s\S]*white-space:\s*pre-line;/);
|
||||
assert.match(css, /\.modal-message a,\s*[\s\S]*\.modal-alert a/);
|
||||
});
|
||||
|
||||
test('openCloudflareTempEmailUsageGuidePage opens the contribution portal home page', () => {
|
||||
const bundle = extractFunction('openCloudflareTempEmailUsageGuidePage');
|
||||
|
||||
const api = new Function(`
|
||||
const openedUrls = [];
|
||||
function getContributionPortalUrl() { return 'https://apikey.qzz.io'; }
|
||||
function openExternalUrl(url) { openedUrls.push(url); }
|
||||
${bundle}
|
||||
return {
|
||||
openedUrls,
|
||||
openCloudflareTempEmailUsageGuidePage,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.openCloudflareTempEmailUsageGuidePage();
|
||||
assert.deepEqual(api.openedUrls, ['https://apikey.qzz.io']);
|
||||
});
|
||||
|
||||
test('openCloudflareTempEmailUsageGuidePage skips opening when the contribution portal URL is empty', () => {
|
||||
const bundle = extractFunction('openCloudflareTempEmailUsageGuidePage');
|
||||
|
||||
const api = new Function(`
|
||||
const openedUrls = [];
|
||||
function getContributionPortalUrl() { return ''; }
|
||||
function openExternalUrl(url) { openedUrls.push(url); }
|
||||
${bundle}
|
||||
return {
|
||||
openedUrls,
|
||||
openCloudflareTempEmailUsageGuidePage,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.openCloudflareTempEmailUsageGuidePage();
|
||||
assert.deepEqual(api.openedUrls, []);
|
||||
});
|
||||
|
||||
test('openCloudflareTempEmailRepositoryPage opens the upstream repository', () => {
|
||||
const bundle = extractFunction('openCloudflareTempEmailRepositoryPage');
|
||||
|
||||
const api = new Function(`
|
||||
const calls = [];
|
||||
const CLOUDFLARE_TEMP_EMAIL_REPOSITORY_URL = 'https://github.com/dreamhunter2333/cloudflare_temp_email';
|
||||
function openExternalUrl(url) { calls.push(url); }
|
||||
${bundle}
|
||||
return {
|
||||
calls,
|
||||
openCloudflareTempEmailRepositoryPage,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.openCloudflareTempEmailRepositoryPage();
|
||||
assert.deepEqual(api.calls, ['https://github.com/dreamhunter2333/cloudflare_temp_email']);
|
||||
});
|
||||
|
||||
test('applyCloudflareTempEmailSettingsState restores the random subdomain toggle and temp domain list', () => {
|
||||
const bundle = extractFunction('applyCloudflareTempEmailSettingsState');
|
||||
|
||||
const api = new Function(`
|
||||
const inputTempEmailBaseUrl = { value: '' };
|
||||
const inputTempEmailAdminAuth = { value: '' };
|
||||
const inputTempEmailCustomAuth = { value: '' };
|
||||
const inputTempEmailReceiveMailbox = { value: '' };
|
||||
const inputTempEmailUseRandomSubdomain = { checked: false };
|
||||
const calls = {
|
||||
domainOptions: [],
|
||||
domainEditMode: [],
|
||||
};
|
||||
function renderCloudflareTempEmailDomainOptions(value) { calls.domainOptions.push(value); }
|
||||
function setCloudflareTempEmailDomainEditMode(editing, options) { calls.domainEditMode.push({ editing, options }); }
|
||||
${bundle}
|
||||
return {
|
||||
applyCloudflareTempEmailSettingsState,
|
||||
calls,
|
||||
inputTempEmailBaseUrl,
|
||||
inputTempEmailAdminAuth,
|
||||
inputTempEmailCustomAuth,
|
||||
inputTempEmailReceiveMailbox,
|
||||
inputTempEmailUseRandomSubdomain,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.applyCloudflareTempEmailSettingsState({
|
||||
cloudflareTempEmailBaseUrl: 'https://temp.example.com',
|
||||
cloudflareTempEmailAdminAuth: 'admin-secret',
|
||||
cloudflareTempEmailCustomAuth: 'custom-secret',
|
||||
cloudflareTempEmailReceiveMailbox: 'relay@example.com',
|
||||
cloudflareTempEmailUseRandomSubdomain: true,
|
||||
cloudflareTempEmailDomain: 'mail.example.com',
|
||||
});
|
||||
|
||||
assert.equal(api.inputTempEmailBaseUrl.value, 'https://temp.example.com');
|
||||
assert.equal(api.inputTempEmailAdminAuth.value, 'admin-secret');
|
||||
assert.equal(api.inputTempEmailCustomAuth.value, 'custom-secret');
|
||||
assert.equal(api.inputTempEmailReceiveMailbox.value, 'relay@example.com');
|
||||
assert.equal(api.inputTempEmailUseRandomSubdomain.checked, true);
|
||||
assert.deepEqual(api.calls.domainOptions, ['mail.example.com']);
|
||||
assert.deepEqual(api.calls.domainEditMode, [{ editing: false, options: { clearInput: true } }]);
|
||||
});
|
||||
|
||||
test('updateMailProviderUI keeps the temp domain selector visible and updates the hint when random subdomain is enabled', () => {
|
||||
const bundle = extractFunction('updateMailProviderUI');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = {
|
||||
cloudflareTempEmailDomains: ['mail.example.com'],
|
||||
};
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
const ICLOUD_PROVIDER = 'icloud';
|
||||
const GMAIL_PROVIDER = 'gmail';
|
||||
const LUCKMAIL_PROVIDER = 'luckmail-api';
|
||||
const rowMail2925Mode = ${JSON.stringify(createRow('none'))};
|
||||
const rowMail2925PoolSettings = ${JSON.stringify(createRow('none'))};
|
||||
const rowEmailPrefix = ${JSON.stringify(createRow('none'))};
|
||||
const rowInbucketHost = ${JSON.stringify(createRow('none'))};
|
||||
const rowInbucketMailbox = ${JSON.stringify(createRow('none'))};
|
||||
const rowEmailGenerator = ${JSON.stringify(createRow(''))};
|
||||
const rowCfDomain = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailBaseUrl = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailAdminAuth = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailCustomAuth = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailReceiveMailbox = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailRandomSubdomainToggle = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailDomain = ${JSON.stringify(createRow('none'))};
|
||||
const cloudflareTempEmailSection = ${JSON.stringify(createRow('none'))};
|
||||
const hotmailSection = ${JSON.stringify(createRow('none'))};
|
||||
const mail2925Section = ${JSON.stringify(createRow('none'))};
|
||||
const luckmailSection = ${JSON.stringify(createRow('none'))};
|
||||
const icloudSection = ${JSON.stringify(createRow('none'))};
|
||||
const labelEmailPrefix = { textContent: '' };
|
||||
const inputEmailPrefix = { placeholder: '', style: { display: '' }, readOnly: false };
|
||||
const labelMail2925UseAccountPool = ${JSON.stringify(createRow('none'))};
|
||||
const selectMail2925PoolAccount = { style: { display: 'none' }, disabled: false };
|
||||
const btnFetchEmail = { hidden: false, disabled: false, textContent: '' };
|
||||
const btnMailLogin = { disabled: false, textContent: '', title: '' };
|
||||
const inputEmail = { readOnly: false, placeholder: '', value: '' };
|
||||
const autoHintText = { textContent: '' };
|
||||
const rowHotmailServiceMode = ${JSON.stringify(createRow('none'))};
|
||||
const rowHotmailRemoteBaseUrl = ${JSON.stringify(createRow('none'))};
|
||||
const rowHotmailLocalBaseUrl = ${JSON.stringify(createRow('none'))};
|
||||
const inputMail2925UseAccountPool = { checked: false };
|
||||
const selectMailProvider = { value: '163' };
|
||||
const selectEmailGenerator = { value: 'cloudflare-temp-email', disabled: false };
|
||||
const inputTempEmailUseRandomSubdomain = { checked: false };
|
||||
const calls = {
|
||||
tempDomainEditMode: [],
|
||||
};
|
||||
function isLuckmailProvider() { return false; }
|
||||
function isCustomMailProvider() { return false; }
|
||||
function isIcloudMailProvider() { return false; }
|
||||
function usesGeneratedAliasMailProvider() { return false; }
|
||||
function getSelectedMail2925Mode() { return 'provide'; }
|
||||
function getManagedAliasProviderUiCopy() { return null; }
|
||||
function getCurrentRegistrationEmailUiCopy() {
|
||||
return {
|
||||
buttonLabel: '生成 Temp',
|
||||
placeholder: '点击生成 Cloudflare Temp Email,或手动粘贴邮箱',
|
||||
label: 'Cloudflare Temp Email',
|
||||
};
|
||||
}
|
||||
function updateMailLoginButtonState() {}
|
||||
function getSelectedHotmailServiceMode() { return 'local'; }
|
||||
function getCloudflareDomainsFromState() { return { domains: [], activeDomain: '' }; }
|
||||
function setCloudflareDomainEditMode() {}
|
||||
function getCloudflareTempEmailDomainsFromState() { return { domains: ['mail.example.com'], activeDomain: 'mail.example.com' }; }
|
||||
function setCloudflareTempEmailDomainEditMode(editing) { calls.tempDomainEditMode.push(editing); }
|
||||
function queueIcloudAliasRefresh() {}
|
||||
function hideIcloudLoginHelp() {}
|
||||
function syncMail2925PoolAccountOptions() {}
|
||||
function getMail2925Accounts() { return []; }
|
||||
function renderHotmailAccounts() {}
|
||||
function renderMail2925Accounts() {}
|
||||
function renderLuckmailPurchases() {}
|
||||
function getSelectedEmailGenerator() { return String(selectEmailGenerator.value || '').trim().toLowerCase(); }
|
||||
function isAutoRunLockedPhase() { return false; }
|
||||
${bundle}
|
||||
return {
|
||||
updateMailProviderUI,
|
||||
cloudflareTempEmailSection,
|
||||
rowTempEmailRandomSubdomainToggle,
|
||||
rowTempEmailDomain,
|
||||
inputTempEmailUseRandomSubdomain,
|
||||
autoHintText,
|
||||
calls,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.updateMailProviderUI();
|
||||
assert.equal(api.cloudflareTempEmailSection.style.display, '');
|
||||
assert.equal(api.rowTempEmailRandomSubdomainToggle.style.display, '');
|
||||
assert.equal(api.rowTempEmailDomain.style.display, '');
|
||||
|
||||
api.inputTempEmailUseRandomSubdomain.checked = true;
|
||||
api.updateMailProviderUI();
|
||||
assert.equal(api.cloudflareTempEmailSection.style.display, '');
|
||||
assert.equal(api.rowTempEmailDomain.style.display, '');
|
||||
assert.match(api.autoHintText.textContent, /RANDOM_SUBDOMAIN_DOMAINS/);
|
||||
});
|
||||
@@ -9,8 +9,8 @@ test('sidepanel html keeps a single contribution mode button in header', () => {
|
||||
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, /id="btn-contribution-mode"[^>]*title="进入贡献模式并打开官网页"/);
|
||||
assert.match(html, />贡献\/使用教程<\/button>/);
|
||||
assert.match(html, /<\/header>\s*<div id="contribution-update-layer"/);
|
||||
assert.match(html, /id="contribution-update-layer"/);
|
||||
assert.match(html, /id="contribution-update-hint"/);
|
||||
|
||||
@@ -135,6 +135,8 @@ const inputSub2ApiEmail = { value: 'user@example.com' };
|
||||
const inputSub2ApiPassword = { value: 'sub-secret' };
|
||||
const inputSub2ApiGroup = { value: ' codex ' };
|
||||
const inputSub2ApiDefaultProxy = { value: ' proxy-a ' };
|
||||
const inputCodex2ApiUrl = { value: 'http://localhost:8080/admin/accounts' };
|
||||
const inputCodex2ApiAdminKey = { value: 'codex-admin-secret' };
|
||||
const inputPassword = { value: 'Secret123!' };
|
||||
const selectMailProvider = { value: '163' };
|
||||
const selectEmailGenerator = { value: 'duck' };
|
||||
@@ -154,6 +156,7 @@ const inputTempEmailBaseUrl = { value: 'https://temp.example.com' };
|
||||
const inputTempEmailAdminAuth = { value: 'admin-secret' };
|
||||
const inputTempEmailCustomAuth = { value: 'custom-secret' };
|
||||
const inputTempEmailReceiveMailbox = { value: 'relay@example.com' };
|
||||
const inputTempEmailUseRandomSubdomain = { checked: true };
|
||||
const inputAutoSkipFailures = { checked: false };
|
||||
const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' };
|
||||
const inputAutoDelayEnabled = { checked: true };
|
||||
@@ -190,12 +193,16 @@ return {
|
||||
assert.equal('customPassword' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryTextEnabled' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryHelperBaseUrl' in contributionPayload, false);
|
||||
assert.equal(contributionPayload.cloudflareTempEmailUseRandomSubdomain, true);
|
||||
|
||||
api.setLatestState({ contributionMode: false });
|
||||
const normalPayload = api.collectSettingsPayload();
|
||||
assert.equal(normalPayload.customPassword, 'Secret123!');
|
||||
assert.equal(normalPayload.accountRunHistoryTextEnabled, true);
|
||||
assert.equal(normalPayload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
|
||||
assert.equal(normalPayload.codex2apiUrl, 'http://localhost:8080/admin/accounts');
|
||||
assert.equal(normalPayload.codex2apiAdminKey, 'codex-admin-secret');
|
||||
assert.equal(normalPayload.cloudflareTempEmailUseRandomSubdomain, true);
|
||||
});
|
||||
|
||||
test('contribution mode manager enters mode, starts main auto flow, polls contribution status, and exits cleanly', async () => {
|
||||
@@ -264,6 +271,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
rowSub2ApiGroup: createElement(),
|
||||
rowSub2ApiPassword: createElement(),
|
||||
rowSub2ApiUrl: createElement(),
|
||||
rowCodex2ApiUrl: createElement(),
|
||||
rowCodex2ApiAdminKey: createElement(),
|
||||
rowVpsPassword: createElement(),
|
||||
rowVpsUrl: createElement(),
|
||||
selectPanelMode: createElement({ value: 'sub2api' }),
|
||||
@@ -393,7 +402,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
},
|
||||
},
|
||||
constants: {
|
||||
contributionUploadUrl: 'https://apikey.qzz.io',
|
||||
contributionPortalUrl: 'https://apikey.qzz.io',
|
||||
contributionUploadUrl: 'https://apikey.qzz.io/upload',
|
||||
pollIntervalMs: 2500,
|
||||
},
|
||||
});
|
||||
@@ -414,6 +424,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
assert.equal(dom.contributionModeSummary.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);
|
||||
assert.equal(dom.rowCodex2ApiAdminKey.classList.contains('is-contribution-hidden'), true);
|
||||
assert.ok(closeConfigMenuCount >= 1);
|
||||
assert.ok(closeAccountRecordsCount >= 1);
|
||||
assert.ok(updatePanelModeCount >= 1);
|
||||
@@ -441,7 +453,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85 CPA \u786e\u8ba4');
|
||||
|
||||
dom.btnOpenContributionUpload.listeners.click();
|
||||
assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io', 'https://apikey.qzz.io']);
|
||||
assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io', 'https://apikey.qzz.io/upload']);
|
||||
|
||||
await dom.btnExitContributionMode.listeners.click();
|
||||
manager.render();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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 helperBundle = [
|
||||
extractFunction('getContributionUpdatePromptLines'),
|
||||
extractFunction('getContributionUpdateHintMessage'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
${helperBundle}
|
||||
return {
|
||||
getContributionUpdatePromptLines,
|
||||
getContributionUpdateHintMessage,
|
||||
};
|
||||
`)();
|
||||
|
||||
test('getContributionUpdateHintMessage numbers contribution updates when both content and questionnaire are visible', () => {
|
||||
const message = api.getContributionUpdateHintMessage({
|
||||
promptVersion: 'announcement:2026-04-23T00:00:00Z|questionnaire:2026-04-23T00:00:01Z',
|
||||
items: [
|
||||
{ slug: 'announcement', isVisible: true },
|
||||
{ slug: 'questionnaire', isVisible: true },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
message,
|
||||
'1. 公告 / 使用教程有更新了,可点上方“贡献/使用”查看。\n2. 有新的征求意见,请佬友共同参与选择。'
|
||||
);
|
||||
});
|
||||
|
||||
test('getContributionUpdateHintMessage returns questionnaire prompt alone when only questionnaire is updated', () => {
|
||||
const message = api.getContributionUpdateHintMessage({
|
||||
promptVersion: 'questionnaire:2026-04-23T00:00:01Z',
|
||||
items: [
|
||||
{ slug: 'questionnaire', isVisible: true },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(message, '有新的征求意见,请佬友共同参与选择。');
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
test('sidepanel html exposes custom email pool generator option and input row', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
|
||||
assert.match(html, /option value="custom-pool">自定义邮箱池<\/option>/);
|
||||
assert.match(html, /id="row-custom-email-pool"/);
|
||||
assert.match(html, /id="input-custom-email-pool"/);
|
||||
assert.match(html, /id="row-custom-mail-provider-pool"/);
|
||||
assert.match(html, /id="input-custom-mail-provider-pool"/);
|
||||
});
|
||||
|
||||
test('sidepanel locks run count to custom email pool size', () => {
|
||||
const bundle = [
|
||||
extractFunction('isCustomMailProvider'),
|
||||
extractFunction('normalizeCustomEmailPoolEntries'),
|
||||
extractFunction('getSelectedEmailGenerator'),
|
||||
extractFunction('usesGeneratedAliasMailProvider'),
|
||||
extractFunction('usesCustomEmailPoolGenerator'),
|
||||
extractFunction('getCustomMailProviderPoolSize'),
|
||||
extractFunction('usesCustomMailProviderPool'),
|
||||
extractFunction('getLockedRunCountFromEmailPool'),
|
||||
extractFunction('getCustomEmailPoolSize'),
|
||||
extractFunction('getRunCountValue'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const GMAIL_PROVIDER = 'gmail';
|
||||
const GMAIL_ALIAS_GENERATOR = 'gmail-alias';
|
||||
const CUSTOM_EMAIL_POOL_GENERATOR = 'custom-pool';
|
||||
const selectMailProvider = { value: 'gmail' };
|
||||
const selectEmailGenerator = { value: 'custom-pool' };
|
||||
const inputCustomEmailPool = { value: 'first@example.com\\nsecond@example.com' };
|
||||
const inputRunCount = { value: '99' };
|
||||
|
||||
function isLuckmailProvider() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isManagedAliasProvider() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSelectedMail2925Mode() {
|
||||
return 'provide';
|
||||
}
|
||||
|
||||
function isManagedAliasProvider(provider) {
|
||||
return String(provider || '').trim().toLowerCase() === GMAIL_PROVIDER;
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
getSelectedEmailGenerator,
|
||||
usesGeneratedAliasMailProvider,
|
||||
usesCustomEmailPoolGenerator,
|
||||
getCustomEmailPoolSize,
|
||||
getRunCountValue,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.getSelectedEmailGenerator(), 'custom-pool');
|
||||
assert.equal(api.usesGeneratedAliasMailProvider('gmail', 'provide', 'gmail-alias'), true);
|
||||
assert.equal(api.usesGeneratedAliasMailProvider('gmail', 'provide', 'custom-pool'), false);
|
||||
assert.equal(api.usesCustomEmailPoolGenerator(), true);
|
||||
assert.equal(api.getCustomEmailPoolSize(), 2);
|
||||
assert.equal(api.getRunCountValue(), 2);
|
||||
});
|
||||
|
||||
test('sidepanel locks run count to custom mail provider pool size', () => {
|
||||
const bundle = [
|
||||
extractFunction('isCustomMailProvider'),
|
||||
extractFunction('normalizeCustomEmailPoolEntries'),
|
||||
extractFunction('getSelectedEmailGenerator'),
|
||||
extractFunction('usesGeneratedAliasMailProvider'),
|
||||
extractFunction('usesCustomEmailPoolGenerator'),
|
||||
extractFunction('getCustomMailProviderPoolSize'),
|
||||
extractFunction('usesCustomMailProviderPool'),
|
||||
extractFunction('getLockedRunCountFromEmailPool'),
|
||||
extractFunction('getCustomEmailPoolSize'),
|
||||
extractFunction('getRunCountValue'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const GMAIL_PROVIDER = 'gmail';
|
||||
const GMAIL_ALIAS_GENERATOR = 'gmail-alias';
|
||||
const CUSTOM_EMAIL_POOL_GENERATOR = 'custom-pool';
|
||||
const selectMailProvider = { value: 'custom' };
|
||||
const selectEmailGenerator = { value: 'duck' };
|
||||
const inputCustomMailProviderPool = { value: 'first@example.com\\nsecond@example.com\\nthird@example.com' };
|
||||
const inputCustomEmailPool = { value: '' };
|
||||
const inputRunCount = { value: '99' };
|
||||
|
||||
function isLuckmailProvider() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isManagedAliasProvider() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSelectedMail2925Mode() {
|
||||
return 'provide';
|
||||
}
|
||||
|
||||
function isManagedAliasProvider(provider) {
|
||||
return String(provider || '').trim().toLowerCase() === GMAIL_PROVIDER;
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
usesCustomMailProviderPool,
|
||||
getCustomMailProviderPoolSize,
|
||||
getLockedRunCountFromEmailPool,
|
||||
getRunCountValue,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.usesCustomMailProviderPool(), true);
|
||||
assert.equal(api.getCustomMailProviderPoolSize(), 3);
|
||||
assert.equal(api.getLockedRunCountFromEmailPool(), 3);
|
||||
assert.equal(api.getRunCountValue(), 3);
|
||||
});
|
||||
|
||||
test('sidepanel custom verification dialog exposes add-phone action for step 8', async () => {
|
||||
const bundle = [
|
||||
extractFunction('getCustomVerificationPromptCopy'),
|
||||
extractFunction('openCustomVerificationConfirmDialog'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let openActionModalPayload = null;
|
||||
|
||||
async function openActionModal(options) {
|
||||
openActionModalPayload = options;
|
||||
return options.buildResult('add_phone');
|
||||
}
|
||||
|
||||
async function openConfirmModal() {
|
||||
throw new Error('step 8 should use action modal');
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
getCustomVerificationPromptCopy,
|
||||
openCustomVerificationConfirmDialog,
|
||||
getOpenActionModalPayload: () => openActionModalPayload,
|
||||
};
|
||||
`)();
|
||||
|
||||
const prompt = api.getCustomVerificationPromptCopy(8);
|
||||
assert.equal(prompt.phoneActionLabel, '出现手机号验证');
|
||||
|
||||
const result = await api.openCustomVerificationConfirmDialog(8);
|
||||
assert.deepEqual(result, {
|
||||
confirmed: false,
|
||||
addPhoneDetected: true,
|
||||
});
|
||||
|
||||
const modalPayload = api.getOpenActionModalPayload();
|
||||
assert.equal(modalPayload.actions.length, 3);
|
||||
assert.equal(modalPayload.actions[1].id, 'add_phone');
|
||||
assert.equal(modalPayload.actions[1].label, '出现手机号验证');
|
||||
});
|
||||
@@ -12,6 +12,23 @@ test('sidepanel loads icloud manager before sidepanel bootstrap', () => {
|
||||
assert.ok(icloudManagerIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('sidepanel source binds the icloud fetch mode control before using it', () => {
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
assert.match(source, /const selectIcloudFetchMode = document\.getElementById\('select-icloud-fetch-mode'\);/);
|
||||
assert.match(source, /selectIcloudFetchMode\?\.addEventListener\('change'/);
|
||||
});
|
||||
|
||||
test('update card highlights exporting config before upgrade', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
const css = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
|
||||
|
||||
assert.match(html, /<p class="update-card-reminder">一定请先导出配置,再执行更新<\/p>/);
|
||||
assert.match(css, /\.update-card-reminder\s*\{/);
|
||||
assert.match(css, /font-weight:\s*700;/);
|
||||
assert.match(css, /color:\s*var\(--orange\);/);
|
||||
});
|
||||
|
||||
test('icloud manager exposes a factory and renders empty state', () => {
|
||||
const source = fs.readFileSync('sidepanel/icloud-manager.js', 'utf8');
|
||||
const windowObject = {};
|
||||
|
||||
@@ -142,3 +142,89 @@ return {
|
||||
assert.equal(api.getLatestState().mail2925BaseEmail, 'new@2925.com');
|
||||
assert.equal(api.getSaveCalls(), 1);
|
||||
});
|
||||
|
||||
test('collectSettingsPayload persists currentMail2925AccountId for 2925 account pool restore', () => {
|
||||
const bundle = [
|
||||
extractFunction('collectSettingsPayload'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = {
|
||||
contributionMode: false,
|
||||
mail2925UseAccountPool: true,
|
||||
currentMail2925AccountId: 'acc-2',
|
||||
};
|
||||
let cloudflareDomainEditMode = false;
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
const selectCfDomain = { value: 'example.com' };
|
||||
const selectTempEmailDomain = { value: 'mail.example.com' };
|
||||
const selectPanelMode = { value: 'cpa' };
|
||||
const inputVpsUrl = { value: '' };
|
||||
const inputVpsPassword = { value: '' };
|
||||
const inputSub2ApiUrl = { value: '' };
|
||||
const inputSub2ApiEmail = { value: '' };
|
||||
const inputSub2ApiPassword = { value: '' };
|
||||
const inputSub2ApiGroup = { value: '' };
|
||||
const inputSub2ApiDefaultProxy = { value: '' };
|
||||
const inputCodex2ApiUrl = { value: '' };
|
||||
const inputCodex2ApiAdminKey = { value: '' };
|
||||
const inputPassword = { value: '' };
|
||||
const selectMailProvider = { value: '2925' };
|
||||
const selectEmailGenerator = { value: 'duck' };
|
||||
const checkboxAutoDeleteIcloud = { checked: false };
|
||||
const selectIcloudHostPreference = { value: 'auto' };
|
||||
const inputAccountRunHistoryTextEnabled = { checked: false };
|
||||
const inputAccountRunHistoryHelperBaseUrl = { value: '' };
|
||||
const inputMail2925UseAccountPool = { checked: true };
|
||||
const inputInbucketHost = { value: '' };
|
||||
const inputInbucketMailbox = { value: '' };
|
||||
const inputHotmailRemoteBaseUrl = { value: '' };
|
||||
const inputHotmailLocalBaseUrl = { value: '' };
|
||||
const inputLuckmailApiKey = { value: '' };
|
||||
const inputLuckmailBaseUrl = { value: '' };
|
||||
const selectLuckmailEmailType = { value: 'ms_graph' };
|
||||
const inputLuckmailDomain = { value: '' };
|
||||
const inputTempEmailBaseUrl = { value: '' };
|
||||
const inputTempEmailAdminAuth = { value: '' };
|
||||
const inputTempEmailCustomAuth = { value: '' };
|
||||
const inputTempEmailReceiveMailbox = { value: '' };
|
||||
const inputTempEmailUseRandomSubdomain = { checked: false };
|
||||
const inputAutoSkipFailures = { checked: false };
|
||||
const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
|
||||
const inputAutoDelayEnabled = { checked: false };
|
||||
const inputAutoDelayMinutes = { value: '30' };
|
||||
const inputAutoStepDelaySeconds = { value: '' };
|
||||
const inputVerificationResendCount = { value: '4' };
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
function getCloudflareDomainsFromState() {
|
||||
return { domains: [], activeDomain: '' };
|
||||
}
|
||||
function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); }
|
||||
function getCloudflareTempEmailDomainsFromState() {
|
||||
return { domains: [], activeDomain: '' };
|
||||
}
|
||||
function normalizeCloudflareTempEmailDomainValue(value) { return String(value || '').trim(); }
|
||||
function getSelectedLocalCpaStep9Mode() { return 'submit'; }
|
||||
function getSelectedMail2925Mode() { return 'provide'; }
|
||||
function getSelectedHotmailServiceMode() { return 'local'; }
|
||||
function buildManagedAliasBaseEmailPayload() {
|
||||
return { gmailBaseEmail: '', mail2925BaseEmail: 'demo@2925.com', emailPrefix: '' };
|
||||
}
|
||||
function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeLuckmailEmailType(value) { return String(value || '').trim() || 'ms_graph'; }
|
||||
function normalizeCloudflareTempEmailBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailReceiveMailboxValue(value) { return String(value || '').trim(); }
|
||||
function normalizeAccountRunHistoryHelperBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
|
||||
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
|
||||
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
|
||||
${bundle}
|
||||
return { collectSettingsPayload };
|
||||
`)();
|
||||
|
||||
const payload = api.collectSettingsPayload();
|
||||
|
||||
assert.equal(payload.currentMail2925AccountId, 'acc-2');
|
||||
assert.equal(payload.mail2925UseAccountPool, true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
test('new user guide prompt is only eligible before the one-time dismissal is set', () => {
|
||||
const bundle = [
|
||||
extractFunction('isPromptDismissed'),
|
||||
extractFunction('setPromptDismissed'),
|
||||
extractFunction('isNewUserGuidePromptDismissed'),
|
||||
extractFunction('setNewUserGuidePromptDismissed'),
|
||||
extractFunction('shouldPromptNewUserGuide'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed';
|
||||
const storage = new Map();
|
||||
const localStorage = {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
};
|
||||
const btnContributionMode = { disabled: false };
|
||||
let latestState = { contributionMode: false };
|
||||
${bundle}
|
||||
return {
|
||||
shouldPromptNewUserGuide,
|
||||
setDismissed(value) {
|
||||
setNewUserGuidePromptDismissed(value);
|
||||
},
|
||||
setButtonDisabled(value) {
|
||||
btnContributionMode.disabled = Boolean(value);
|
||||
},
|
||||
setContributionMode(value) {
|
||||
latestState = { contributionMode: Boolean(value) };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.shouldPromptNewUserGuide(), true);
|
||||
|
||||
api.setDismissed(true);
|
||||
assert.equal(api.shouldPromptNewUserGuide(), false);
|
||||
|
||||
api.setDismissed(false);
|
||||
api.setButtonDisabled(true);
|
||||
assert.equal(api.shouldPromptNewUserGuide(), false);
|
||||
|
||||
api.setButtonDisabled(false);
|
||||
api.setContributionMode(true);
|
||||
assert.equal(api.shouldPromptNewUserGuide(), false);
|
||||
});
|
||||
|
||||
test('new user guide prompt persists dismissal before awaiting the user choice and opens the contribution page on confirm', async () => {
|
||||
const bundle = [
|
||||
extractFunction('isPromptDismissed'),
|
||||
extractFunction('setPromptDismissed'),
|
||||
extractFunction('isNewUserGuidePromptDismissed'),
|
||||
extractFunction('setNewUserGuidePromptDismissed'),
|
||||
extractFunction('shouldPromptNewUserGuide'),
|
||||
extractFunction('getContributionPortalUrl'),
|
||||
extractFunction('openNewUserGuidePrompt'),
|
||||
extractFunction('maybeShowNewUserGuidePrompt'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed';
|
||||
const storage = new Map();
|
||||
const localStorage = {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
};
|
||||
const btnContributionMode = { disabled: false };
|
||||
const latestState = { contributionMode: false };
|
||||
const contributionContentService = { portalUrl: 'https://apikey.qzz.io' };
|
||||
const openedUrls = [];
|
||||
let modalOptions = null;
|
||||
let nextChoice = 'confirm';
|
||||
function openExternalUrl(url) {
|
||||
openedUrls.push(url);
|
||||
}
|
||||
function openActionModal(options) {
|
||||
modalOptions = options;
|
||||
return Promise.resolve(nextChoice);
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
maybeShowNewUserGuidePrompt,
|
||||
getDismissed() {
|
||||
return localStorage.getItem(NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY);
|
||||
},
|
||||
getOpenedUrls() {
|
||||
return openedUrls.slice();
|
||||
},
|
||||
getModalOptions() {
|
||||
return modalOptions;
|
||||
},
|
||||
setNextChoice(choice) {
|
||||
nextChoice = choice;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const confirmed = await api.maybeShowNewUserGuidePrompt();
|
||||
const modalOptions = api.getModalOptions();
|
||||
|
||||
assert.equal(confirmed, true);
|
||||
assert.equal(api.getDismissed(), '1');
|
||||
assert.deepStrictEqual(api.getOpenedUrls(), ['https://apikey.qzz.io']);
|
||||
assert.equal(modalOptions.title, '新手引导');
|
||||
assert.equal(modalOptions.alert.text, '本提示仅出现一次。');
|
||||
assert.deepStrictEqual(
|
||||
modalOptions.actions.map((item) => ({ id: item.id, label: item.label })),
|
||||
[
|
||||
{ id: null, label: '取消' },
|
||||
{ id: 'confirm', label: '查看引导' },
|
||||
]
|
||||
);
|
||||
|
||||
api.setNextChoice(null);
|
||||
const skipped = await api.maybeShowNewUserGuidePrompt();
|
||||
assert.equal(skipped, false);
|
||||
assert.deepStrictEqual(api.getOpenedUrls(), ['https://apikey.qzz.io']);
|
||||
});
|
||||
@@ -144,3 +144,317 @@ return {
|
||||
]);
|
||||
assert.match(result.bodyTextPreview, /Welcome to ChatGPT/);
|
||||
});
|
||||
|
||||
test('signup entry diagnostics captures hidden signup button style and blocking ancestor details', () => {
|
||||
const api = new Function(`
|
||||
const SIGNUP_ENTRY_TRIGGER_PATTERN = /免费注册|立即注册|注册|sign\\s*up|register|create\\s*account|create\\s+account/i;
|
||||
const location = { href: 'https://chatgpt.com/' };
|
||||
const hiddenSection = {
|
||||
tagName: 'DIV',
|
||||
id: 'mobile-cta',
|
||||
className: 'max-xs:hidden',
|
||||
hidden: false,
|
||||
parentElement: null,
|
||||
hasAttribute() {
|
||||
return false;
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-hidden') return '';
|
||||
return '';
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 0, height: 0 };
|
||||
},
|
||||
_style: {
|
||||
display: 'none',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const hiddenSignupButton = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Sign up for free',
|
||||
disabled: false,
|
||||
className: 'signup-button',
|
||||
hidden: false,
|
||||
parentElement: hiddenSection,
|
||||
hasAttribute() {
|
||||
return false;
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 0, height: 0 };
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return '';
|
||||
if (name === 'aria-hidden') return '';
|
||||
return '';
|
||||
},
|
||||
_style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const document = {
|
||||
title: 'ChatGPT',
|
||||
readyState: 'complete',
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
|
||||
return [hiddenSignupButton];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
outerWidth: 390,
|
||||
outerHeight: 844,
|
||||
devicePixelRatio: 3,
|
||||
getComputedStyle(el) {
|
||||
return el?._style || {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function isVisibleElement(el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
}
|
||||
|
||||
function getActionText(el) {
|
||||
return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isActionEnabled(el) {
|
||||
return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
function getSignupEmailInput() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSignupPasswordInput() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPageTextSnapshot() {
|
||||
return 'ChatGPT 登录';
|
||||
}
|
||||
|
||||
${extractFunction('getSignupEntryDiagnostics')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return getSignupEntryDiagnostics();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = api.run();
|
||||
|
||||
assert.deepStrictEqual(result.viewport, {
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
outerWidth: 390,
|
||||
outerHeight: 844,
|
||||
devicePixelRatio: 3,
|
||||
});
|
||||
assert.deepStrictEqual(result.signupLikeActionCounts, {
|
||||
total: 1,
|
||||
visible: 0,
|
||||
hidden: 1,
|
||||
});
|
||||
assert.equal(result.signupLikeActions[0]?.text, 'Sign up for free');
|
||||
assert.equal(result.signupLikeActions[0]?.className, 'signup-button');
|
||||
assert.equal(result.signupLikeActions[0]?.display, 'block');
|
||||
assert.equal(result.signupLikeActions[0]?.blockingAncestor?.className, 'max-xs:hidden');
|
||||
assert.equal(result.signupLikeActions[0]?.blockingAncestor?.display, 'none');
|
||||
});
|
||||
|
||||
test('signup password diagnostics summarizes password inputs, submit button, and alternate code entry', () => {
|
||||
const api = new Function(`
|
||||
const location = { href: 'https://auth.openai.com/create-account/password' };
|
||||
const form = { action: 'https://auth.openai.com/u/signup/password' };
|
||||
const passwordInput = {
|
||||
tagName: 'INPUT',
|
||||
type: 'password',
|
||||
name: 'new-password',
|
||||
id: 'password-field',
|
||||
value: 'SecretLength14',
|
||||
className: 'password-input',
|
||||
disabled: false,
|
||||
form,
|
||||
getBoundingClientRect() {
|
||||
return { width: 320, height: 44 };
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'password';
|
||||
if (name === 'name') return 'new-password';
|
||||
if (name === 'autocomplete') return 'new-password';
|
||||
if (name === 'placeholder') return 'Password';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
_style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const submitButton = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Continue',
|
||||
className: 'submit-btn',
|
||||
disabled: false,
|
||||
form,
|
||||
getBoundingClientRect() {
|
||||
return { width: 160, height: 40 };
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'submit';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
if (name === 'data-dd-action-name') return 'Continue';
|
||||
return '';
|
||||
},
|
||||
_style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const oneTimeCodeButton = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Use a one-time code instead',
|
||||
className: 'switch-btn',
|
||||
disabled: false,
|
||||
getBoundingClientRect() {
|
||||
return { width: 220, height: 36 };
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'button';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
_style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const document = {
|
||||
title: 'Create your account',
|
||||
readyState: 'complete',
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[type="password"], input[name*="password" i], input[autocomplete="new-password"], input[autocomplete="current-password"]') {
|
||||
return [passwordInput];
|
||||
}
|
||||
if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
|
||||
return [submitButton, oneTimeCodeButton];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
getComputedStyle(el) {
|
||||
return el?._style || {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function isVisibleElement(el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function getPageTextSnapshot() {
|
||||
return 'Create your account Use a one-time code instead';
|
||||
}
|
||||
|
||||
function getSignupPasswordInput() {
|
||||
return passwordInput;
|
||||
}
|
||||
|
||||
function getSignupPasswordSubmitButton() {
|
||||
return submitButton;
|
||||
}
|
||||
|
||||
function getSignupPasswordDisplayedEmail() {
|
||||
return 'user@example.com';
|
||||
}
|
||||
|
||||
function findOneTimeCodeLoginTrigger() {
|
||||
return oneTimeCodeButton;
|
||||
}
|
||||
|
||||
function getSignupPasswordTimeoutErrorPageState() {
|
||||
return {
|
||||
retryEnabled: true,
|
||||
userAlreadyExistsBlocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
${extractFunction('getSignupPasswordDiagnostics')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return getSignupPasswordDiagnostics();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = api.run();
|
||||
|
||||
assert.equal(result.url, 'https://auth.openai.com/create-account/password');
|
||||
assert.equal(result.displayedEmail, 'user@example.com');
|
||||
assert.equal(result.hasVisiblePasswordInput, true);
|
||||
assert.equal(result.passwordInputCount, 1);
|
||||
assert.equal(result.visiblePasswordInputCount, 1);
|
||||
assert.equal(result.passwordInputs[0]?.name, 'new-password');
|
||||
assert.equal(result.passwordInputs[0]?.valueLength, 14);
|
||||
assert.equal(result.submitButton?.text, 'Continue');
|
||||
assert.equal(result.oneTimeCodeTrigger?.text, 'Use a one-time code instead');
|
||||
assert.equal(result.retryPage, true);
|
||||
assert.equal(result.retryEnabled, true);
|
||||
assert.match(result.bodyTextPreview, /one-time code/);
|
||||
});
|
||||
|
||||
@@ -115,6 +115,9 @@ function fillInput(el, value) {
|
||||
filledValues.push(value);
|
||||
}
|
||||
async function sleep() {}
|
||||
function isStep5Ready() { return false; }
|
||||
function isStep8Ready() { return false; }
|
||||
function isAddPhonePageReady() { return false; }
|
||||
function isVisibleElement() { return true; }
|
||||
function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
|
||||
function getActionText(el) { return el.textContent || ''; }
|
||||
|
||||
@@ -87,7 +87,8 @@ test('step6LoginFromPasswordPage switches to one-time-code login when password i
|
||||
globalThis.log = (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
};
|
||||
globalThis.step6SwitchToOneTimeCodeLogin = async (value) => {
|
||||
globalThis.step6SwitchToOneTimeCodeLogin = async (payload, value) => {
|
||||
assert.deepStrictEqual(payload, { email: 'user@example.com', password: '' });
|
||||
assert.strictEqual(value, snapshot);
|
||||
return { step6Outcome: 'success', via: 'switch_to_one_time_code_login' };
|
||||
};
|
||||
|
||||
@@ -72,12 +72,18 @@ async function recoverCurrentAuthRetryPage() {
|
||||
return { recovered: true };
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoveryTransition')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoverableResult')}
|
||||
|
||||
return {
|
||||
@@ -104,6 +110,141 @@ return {
|
||||
assert.equal(result.message, '当前页面处于登录超时报错页。');
|
||||
});
|
||||
|
||||
test('step 7 timeout recovery transition continues from password page after retry succeeds', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
let recoverCalls = 0;
|
||||
let currentState = 'login_timeout_error_page';
|
||||
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/log-in',
|
||||
};
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
return {
|
||||
state: currentState,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function recoverCurrentAuthRetryPage() {
|
||||
recoverCalls += 1;
|
||||
currentState = 'password_page';
|
||||
return { recovered: true };
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoveryTransition')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return createStep6LoginTimeoutRecoveryTransition(
|
||||
'login_timeout_error_page',
|
||||
{ state: 'login_timeout_error_page', url: location.href },
|
||||
'当前页面处于登录超时报错页。',
|
||||
{
|
||||
via: 'login_timeout_initial_recovered',
|
||||
}
|
||||
);
|
||||
},
|
||||
snapshot() {
|
||||
return { logs, recoverCalls };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.equal(snapshot.recoverCalls, 1);
|
||||
assert.equal(result.action, 'password');
|
||||
assert.equal(result.snapshot.state, 'password_page');
|
||||
assert.equal(snapshot.logs.some(({ message }) => /密码页/.test(message)), true);
|
||||
});
|
||||
|
||||
test('step 7 entry resumes password flow after retry page recovery reaches password page', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
let recoverCalls = 0;
|
||||
let currentState = 'login_timeout_error_page';
|
||||
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/log-in',
|
||||
};
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
return {
|
||||
state: currentState,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function recoverCurrentAuthRetryPage() {
|
||||
recoverCalls += 1;
|
||||
currentState = 'password_page';
|
||||
return { recovered: true };
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
async function step6LoginFromPasswordPage(payload, snapshot) {
|
||||
return { branch: 'password', payload, snapshot };
|
||||
}
|
||||
|
||||
async function step6LoginFromEmailPage(payload, snapshot) {
|
||||
return { branch: 'email', payload, snapshot };
|
||||
}
|
||||
|
||||
async function finalizeStep6VerificationReady(options) {
|
||||
return { branch: 'verification', options };
|
||||
}
|
||||
|
||||
function throwForStep6FatalState() {}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoveryTransition')}
|
||||
${extractFunction('step6_login')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return step6_login({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
});
|
||||
},
|
||||
snapshot() {
|
||||
return { logs, recoverCalls };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.equal(snapshot.recoverCalls, 1);
|
||||
assert.equal(result.branch, 'password');
|
||||
assert.equal(result.snapshot.state, 'password_page');
|
||||
assert.equal(snapshot.logs.some(({ message }) => /密码页/.test(message)), true);
|
||||
});
|
||||
|
||||
test('step 7 finalize converts verification page that falls into retry page into recoverable result', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
@@ -150,6 +291,8 @@ function getLoginAuthStateLabel(snapshot) {
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoveryTransition')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoverableResult')}
|
||||
${extractFunction('finalizeStep6VerificationReady')}
|
||||
|
||||
|
||||
@@ -51,10 +51,8 @@ function extractFunction(name) {
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('step 8 click effect returns restart_current_step when retry page is recovered', async () => {
|
||||
test('step 8 click effect throws when retry page appears after clicking continue', async () => {
|
||||
const api = new Function(`
|
||||
let recoverCalls = 0;
|
||||
|
||||
const chrome = {
|
||||
tabs: {
|
||||
async get() {
|
||||
@@ -69,10 +67,6 @@ const chrome = {
|
||||
function throwIfStopped() {}
|
||||
async function sleepWithStop() {}
|
||||
async function ensureStep8SignupPageReady() {}
|
||||
async function recoverAuthRetryPageOnTab() {
|
||||
recoverCalls += 1;
|
||||
return { recovered: true };
|
||||
}
|
||||
async function getStep8PageState() {
|
||||
return {
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
@@ -89,20 +83,120 @@ return {
|
||||
async run() {
|
||||
return waitForStep8ClickEffect(88, 'https://auth.openai.com/authorize', 1000);
|
||||
},
|
||||
snapshot() {
|
||||
return { recoverCalls };
|
||||
};
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.run(),
|
||||
/点击“继续”后页面进入认证页重试页/
|
||||
);
|
||||
});
|
||||
|
||||
test('step 8 ready check throws when consent page is already a retry page before clicking', async () => {
|
||||
const api = new Function(`
|
||||
const chrome = {
|
||||
tabs: {
|
||||
async get() {
|
||||
return {
|
||||
id: 88,
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleepWithStop() {}
|
||||
async function ensureStep8SignupPageReady() {}
|
||||
async function getStep8PageState() {
|
||||
return {
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
retryPage: true,
|
||||
addPhonePage: false,
|
||||
consentReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
${extractFunction('waitForStep8Ready')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return waitForStep8Ready(88, 1000);
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
progressed: false,
|
||||
reason: 'retry_page_recovered',
|
||||
restartCurrentStep: true,
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
});
|
||||
assert.equal(snapshot.recoverCalls, 1);
|
||||
await assert.rejects(
|
||||
() => api.run(),
|
||||
/当前认证页已进入重试页/
|
||||
);
|
||||
});
|
||||
|
||||
test('step 8 ready check completes phone verification flow before waiting for OAuth consent', async () => {
|
||||
const api = new Function(`
|
||||
let pollCount = 0;
|
||||
const phoneVerificationCalls = [];
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleepWithStop() {}
|
||||
async function ensureStep8SignupPageReady() {}
|
||||
const phoneVerificationHelpers = {
|
||||
async completePhoneVerificationFlow(tabId, pageState) {
|
||||
phoneVerificationCalls.push({ tabId, pageState });
|
||||
return {
|
||||
success: true,
|
||||
consentReady: true,
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
};
|
||||
},
|
||||
};
|
||||
async function getStep8PageState() {
|
||||
pollCount += 1;
|
||||
if (pollCount === 1) {
|
||||
return {
|
||||
url: 'https://auth.openai.com/add-phone',
|
||||
addPhonePage: true,
|
||||
phoneVerificationPage: false,
|
||||
consentReady: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
addPhonePage: false,
|
||||
phoneVerificationPage: false,
|
||||
consentReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
${extractFunction('waitForStep8Ready')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return {
|
||||
result: await waitForStep8Ready(88, 1000),
|
||||
phoneVerificationCalls,
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const { result, phoneVerificationCalls } = await api.run();
|
||||
|
||||
assert.deepStrictEqual(phoneVerificationCalls, [
|
||||
{
|
||||
tabId: 88,
|
||||
pageState: {
|
||||
url: 'https://auth.openai.com/add-phone',
|
||||
addPhonePage: true,
|
||||
phoneVerificationPage: false,
|
||||
consentReady: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(result, {
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
addPhonePage: false,
|
||||
phoneVerificationPage: false,
|
||||
consentReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,23 +58,36 @@ const bundle = [
|
||||
extractFunction('summarizeStatusBadgeEntries'),
|
||||
extractFunction('normalizeStep9StatusText'),
|
||||
extractFunction('isOAuthCallbackTimeoutFailure'),
|
||||
extractFunction('isStep10CallbackSubmittedStatus'),
|
||||
extractFunction('isStep10CallbackFailureText'),
|
||||
extractFunction('isStep10MainWaitingStatus'),
|
||||
extractFunction('isStep10MainFailureText'),
|
||||
extractFunction('isStep9FailureText'),
|
||||
extractFunction('isStep9SuccessStatus'),
|
||||
extractFunction('isStep9SuccessLikeStatus'),
|
||||
extractFunction('formatStep10StatusSummaryValue'),
|
||||
extractFunction('isStep10BrowserSwitchRequiredConflict'),
|
||||
extractFunction('getStep10BrowserSwitchRequiredMessage'),
|
||||
extractFunction('buildStep9StatusDiagnostics'),
|
||||
extractFunction('extractStep10FailureDetail'),
|
||||
extractFunction('explainStep10Failure'),
|
||||
].join('\n');
|
||||
|
||||
function createApi() {
|
||||
return new Function(`
|
||||
function isRecoverableStep9AuthFailure(text) {
|
||||
return /(?:认证失败|回调 URL 提交失败):\\s*/i.test(String(text || '').trim())
|
||||
|| /oauth flow is not pending/i.test(String(text || '').trim());
|
||||
const normalized = String(text || '').trim();
|
||||
return /(?:认证失败|回调\\s*url\\s*提交失败|回调url提交失败|提交回调失败)\\s*[::]?/i.test(normalized)
|
||||
|| /oauth flow is not pending|请更新\\s*cli\\s*proxy\\s*api\\s*或检查连接|bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out/i.test(normalized);
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
buildStep9StatusDiagnostics,
|
||||
explainStep10Failure,
|
||||
isStep10BrowserSwitchRequiredConflict,
|
||||
getStep10BrowserSwitchRequiredMessage,
|
||||
};
|
||||
`)();
|
||||
}
|
||||
@@ -86,6 +99,7 @@ test('step 9 does not treat red success badges as exact success', () => {
|
||||
visible: true,
|
||||
text: '认证成功!',
|
||||
className: 'status-badge text-danger',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: true,
|
||||
errorVisualSummary: 'color=rgb(220, 38, 38)',
|
||||
},
|
||||
@@ -104,23 +118,118 @@ test('step 9 keeps failure state dominant when success badge and error banner co
|
||||
visible: true,
|
||||
text: '认证成功!',
|
||||
className: 'status-badge',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
visible: true,
|
||||
text: '回调 URL 提交失败: oauth flow is not pending',
|
||||
className: 'alert alert-danger',
|
||||
className: 'status-badge error',
|
||||
location: 'callback',
|
||||
hasErrorVisualSignal: true,
|
||||
errorVisualSummary: 'color=rgb(220, 38, 38)',
|
||||
},
|
||||
],
|
||||
[],
|
||||
'page'
|
||||
);
|
||||
|
||||
assert.equal(diagnostics.hasExactSuccessVisibleBadge, true);
|
||||
assert.equal(diagnostics.hasFailureVisibleBadge, true);
|
||||
assert.equal(diagnostics.failureText, '回调 URL 提交失败: oauth flow is not pending');
|
||||
assert.equal(diagnostics.failureSource, 'callback');
|
||||
});
|
||||
|
||||
test('step 10 treats plain 认证成功 as success when no failure is visible', () => {
|
||||
const api = createApi();
|
||||
const diagnostics = api.buildStep9StatusDiagnostics(
|
||||
[
|
||||
{
|
||||
visible: true,
|
||||
text: '认证成功',
|
||||
className: 'status-badge',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
],
|
||||
[],
|
||||
'page'
|
||||
);
|
||||
|
||||
assert.equal(diagnostics.hasExactSuccessVisibleBadge, true);
|
||||
assert.equal(diagnostics.exactSuccessText, '认证成功');
|
||||
});
|
||||
|
||||
test('step 10 recognizes callback accepted badge as in-progress signal', () => {
|
||||
const api = createApi();
|
||||
const diagnostics = api.buildStep9StatusDiagnostics(
|
||||
[
|
||||
{
|
||||
visible: true,
|
||||
text: '回调 URL 已提交,等待认证中...',
|
||||
className: 'status-badge success',
|
||||
location: 'callback',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
{
|
||||
visible: true,
|
||||
text: '等待认证中...',
|
||||
className: 'status-badge',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
],
|
||||
[],
|
||||
'page'
|
||||
);
|
||||
|
||||
assert.equal(diagnostics.hasCallbackSubmittedBadge, true);
|
||||
assert.equal(diagnostics.callbackSubmittedText, '回调 URL 已提交,等待认证中...');
|
||||
assert.equal(diagnostics.mainWaitingText, '等待认证中...');
|
||||
assert.equal(diagnostics.hasExactSuccessVisibleBadge, false);
|
||||
});
|
||||
|
||||
test('step 10 explains callback upgrade hint with user-friendly reason', () => {
|
||||
const api = createApi();
|
||||
const explanation = api.explainStep10Failure(
|
||||
'回调 URL 提交失败: 请更新CLI Proxy API或检查连接',
|
||||
'callback'
|
||||
);
|
||||
|
||||
assert.equal(explanation.code, 'callback_submit_api_unavailable');
|
||||
assert.match(explanation.userMessage, /CLI Proxy API 版本过旧|管理接口未启动|连接异常/);
|
||||
assert.match(explanation.userMessage, /回调提交阶段/);
|
||||
});
|
||||
|
||||
test('step 10 requests browser switch when success badge and callback upgrade failure coexist', () => {
|
||||
const api = createApi();
|
||||
const diagnostics = api.buildStep9StatusDiagnostics(
|
||||
[
|
||||
{
|
||||
visible: true,
|
||||
text: '认证成功',
|
||||
className: 'status-badge success',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
{
|
||||
visible: true,
|
||||
text: '回调 URL 提交失败: 请更新CLI Proxy API或检查连接',
|
||||
className: 'status-badge error',
|
||||
location: 'callback',
|
||||
hasErrorVisualSignal: true,
|
||||
errorVisualSummary: 'color=rgb(220, 38, 38)',
|
||||
},
|
||||
],
|
||||
[],
|
||||
'page'
|
||||
);
|
||||
|
||||
assert.equal(api.isStep10BrowserSwitchRequiredConflict(diagnostics), true);
|
||||
assert.match(api.getStep10BrowserSwitchRequiredMessage(diagnostics), /更换浏览器后重新进行注册登录/);
|
||||
});
|
||||
|
||||
@@ -348,6 +348,51 @@ test('verification flow completes step 8 and flags phone verification when add-p
|
||||
]);
|
||||
});
|
||||
|
||||
test('verification flow treats manual step 8 add-phone confirmation as the same fatal add-phone error', async () => {
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {
|
||||
throw new Error('should not complete step 8');
|
||||
},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({
|
||||
confirmed: false,
|
||||
addPhoneDetected: true,
|
||||
}),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
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 () => ({}),
|
||||
sendToMailContentScriptResilient: async () => ({}),
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {
|
||||
throw new Error('should not mark step skipped when add-phone is chosen');
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => helpers.confirmCustomVerificationStepBypass(8),
|
||||
/验证码提交后页面进入手机号页面/
|
||||
);
|
||||
});
|
||||
|
||||
test('verification flow caps mail polling timeout to the remaining oauth budget', async () => {
|
||||
const mailPollCalls = [];
|
||||
|
||||
@@ -716,3 +761,165 @@ test('verification flow uses configured login resend count for step 8', async ()
|
||||
assert.deepStrictEqual(resendSteps, [8, 8]);
|
||||
assert.equal(pollCalls, 3);
|
||||
});
|
||||
|
||||
test('verification flow waits during resend cooldown instead of tight-looping', async () => {
|
||||
const sleepCalls = [];
|
||||
let pollCalls = 0;
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: { tabs: { update: async () => {} } },
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
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 () => ({}),
|
||||
sendToMailContentScriptResilient: async (_mail, message) => {
|
||||
if (message.type !== 'POLL_EMAIL') {
|
||||
return {};
|
||||
}
|
||||
pollCalls += 1;
|
||||
return pollCalls === 1
|
||||
? {}
|
||||
: { code: '654321', emailTimestamp: 123 };
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async (ms) => {
|
||||
sleepCalls.push(ms);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
const result = await helpers.pollFreshVerificationCodeWithResendInterval(
|
||||
4,
|
||||
{
|
||||
email: 'user@example.com',
|
||||
lastSignupCode: null,
|
||||
},
|
||||
{ provider: 'qq', label: 'QQ 邮箱' },
|
||||
{
|
||||
maxResendRequests: 0,
|
||||
resendIntervalMs: 25000,
|
||||
lastResendAt: Date.now(),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.code, '654321');
|
||||
assert.equal(pollCalls, 2);
|
||||
assert.ok(sleepCalls.length >= 1);
|
||||
assert.ok(sleepCalls[0] >= 1000);
|
||||
});
|
||||
|
||||
test('verification flow uses resilient signup-page transport when submitting verification code', async () => {
|
||||
const resilientCalls = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
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 () => {
|
||||
throw new Error('should not use non-resilient channel');
|
||||
},
|
||||
sendToContentScriptResilient: async (_source, message, options) => {
|
||||
resilientCalls.push({ message, options });
|
||||
return { success: true };
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => ({}),
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
const result = await helpers.submitVerificationCode(4, '654321');
|
||||
|
||||
assert.deepStrictEqual(result, { success: true });
|
||||
assert.equal(resilientCalls.length, 1);
|
||||
assert.equal(resilientCalls[0].message.type, 'FILL_CODE');
|
||||
assert.equal(resilientCalls[0].message.payload.code, '654321');
|
||||
assert.ok(resilientCalls[0].options.timeoutMs >= 30000);
|
||||
});
|
||||
|
||||
test('verification flow treats retryable submit transport failure as success when step 4 already redirected to logged-in home', async () => {
|
||||
const logs = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
get: async () => ({ url: 'https://chatgpt.com/' }),
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isRetryableContentScriptTransportError: (error) => /message channel is closed/i.test(String(error?.message || error || '')),
|
||||
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 () => {
|
||||
throw new Error('should not use non-resilient channel');
|
||||
},
|
||||
sendToContentScriptResilient: async () => {
|
||||
throw new Error('The page keeping the extension port is moved into back/forward cache, so the message channel is closed.');
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => ({}),
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
const result = await helpers.submitVerificationCode(4, '654321');
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.skipProfileStep, true);
|
||||
assert.equal(result.assumed, true);
|
||||
assert.equal(result.transportRecovered, true);
|
||||
assert.equal(logs.some(({ message }) => /验证码提交后页面已切换到ChatGPT 已登录首页/.test(message)), true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user