Merge branch 'master' into codex/upstream-pr
This commit is contained in:
@@ -18,11 +18,11 @@ function createRetryButton() {
|
||||
function createRecoveryApi(state) {
|
||||
const retryButton = createRetryButton();
|
||||
global.location = {
|
||||
pathname: '/log-in',
|
||||
href: 'https://auth.openai.com/log-in',
|
||||
pathname: state.pathname || '/log-in',
|
||||
href: state.href || `https://auth.openai.com${state.pathname || '/log-in'}`,
|
||||
};
|
||||
global.document = {
|
||||
title: 'Something went wrong',
|
||||
title: state.title ?? 'Something went wrong',
|
||||
querySelector(selector) {
|
||||
if (selector === 'button[data-dd-action-name="Try again"]' && state.retryVisible) {
|
||||
return retryButton;
|
||||
@@ -42,6 +42,7 @@ function createRecoveryApi(state) {
|
||||
isActionEnabled: (element) => Boolean(element) && !element.disabled && element.getAttribute('aria-disabled') !== 'true',
|
||||
isVisibleElement: () => true,
|
||||
log: () => {},
|
||||
routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405/i,
|
||||
simulateClick: () => {
|
||||
state.clickCount += 1;
|
||||
if (typeof state.onClick === 'function') {
|
||||
@@ -78,9 +79,30 @@ test('auth page recovery detects retry page state', () => {
|
||||
assert.equal(snapshot.retryEnabled, true);
|
||||
assert.equal(snapshot.titleMatched, true);
|
||||
assert.equal(snapshot.detailMatched, false);
|
||||
assert.equal(snapshot.routeErrorMatched, false);
|
||||
assert.equal(snapshot.maxCheckAttemptsBlocked, false);
|
||||
});
|
||||
|
||||
test('auth page recovery detects route error retry page on email verification route', () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
pageText: 'Route Error (405 Method Not Allowed): email-verification action missing.',
|
||||
pathname: '/email-verification',
|
||||
retryVisible: true,
|
||||
title: '',
|
||||
};
|
||||
const api = createRecoveryApi(state);
|
||||
|
||||
const snapshot = api.getAuthTimeoutErrorPageState({
|
||||
pathPatterns: [/\/email-verification(?:[/?#]|$)/i],
|
||||
});
|
||||
|
||||
assert.equal(Boolean(snapshot), true);
|
||||
assert.equal(snapshot.titleMatched, false);
|
||||
assert.equal(snapshot.detailMatched, false);
|
||||
assert.equal(snapshot.routeErrorMatched, true);
|
||||
});
|
||||
|
||||
test('auth page recovery clicks retry and waits until page recovers', async () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
@@ -182,3 +204,25 @@ test('auth page recovery throws cloudflare security blocked error on max_check_a
|
||||
);
|
||||
});
|
||||
|
||||
test('auth page recovery throws signup user already exists error without clicking retry', async () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
pageText: 'Something went wrong. user_already_exists.',
|
||||
pathname: '/email-verification',
|
||||
retryVisible: true,
|
||||
};
|
||||
const api = createRecoveryApi(state);
|
||||
|
||||
await assert.rejects(
|
||||
() => api.recoverAuthRetryPage({
|
||||
logLabel: '步骤 4:检测到注册认证重试页,正在点击“重试”恢复',
|
||||
pathPatterns: [/\/email-verification(?:[/?#]|$)/i],
|
||||
step: 4,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
/SIGNUP_USER_ALREADY_EXISTS::/
|
||||
);
|
||||
|
||||
assert.equal(state.clickCount, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -168,3 +168,166 @@ test('auto-run controller skips add-phone failures to the next round instead of
|
||||
assert.equal(runtime.state.autoRunActive, false);
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
|
||||
test('auto-run controller skips user_already_exists failures to the next round instead of retrying the same round', 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: '163',
|
||||
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 || {}) },
|
||||
}),
|
||||
getStopRequested: () => false,
|
||||
hasSavedProgress: () => false,
|
||||
isAddPhoneAuthFailure: () => false,
|
||||
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 === 1) {
|
||||
throw new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
|
||||
}
|
||||
},
|
||||
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(2, {
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
});
|
||||
|
||||
assert.equal(events.runCalls, 2, 'user_already_exists failure should skip the current round and continue with the next round');
|
||||
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'user_already_exists failure should not enter same-round retrying');
|
||||
assert.equal(events.accountRecords.length, 1, 'user_already_exists should still persist a failed round record');
|
||||
assert.equal(events.accountRecords[0].status, 'failed');
|
||||
assert.match(events.accountRecords[0].reason, /SIGNUP_USER_ALREADY_EXISTS::/);
|
||||
assert.ok(events.logs.some(({ message }) => /继续下一轮/.test(message)));
|
||||
assert.equal(runtime.state.autoRunActive, false);
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ function extractFunction(name) {
|
||||
const bundle = [
|
||||
extractFunction('isAddPhoneAuthUrl'),
|
||||
extractFunction('isAddPhoneAuthState'),
|
||||
extractFunction('isSignupUserAlreadyExistsFailure'),
|
||||
extractFunction('getPostStep6AutoRestartDecision'),
|
||||
extractFunction('runAutoSequenceFromStep'),
|
||||
].join('\n');
|
||||
@@ -207,3 +208,124 @@ return {
|
||||
assert.equal(currentState.password, 'Secret123!');
|
||||
assert.equal(events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), true);
|
||||
});
|
||||
|
||||
test('auto-run does not restart step 4 current attempt when user_already_exists is detected', 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: 'existing@example.com',
|
||||
password: 'Secret123!',
|
||||
mailProvider: '163',
|
||||
stepStatuses: {
|
||||
1: 'pending',
|
||||
2: 'pending',
|
||||
3: 'pending',
|
||||
4: 'pending',
|
||||
5: 'pending',
|
||||
6: 'pending',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
};
|
||||
const events = {
|
||||
steps: [],
|
||||
invalidations: [],
|
||||
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 === 4) {
|
||||
throw new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
|
||||
}
|
||||
}
|
||||
|
||||
async function getTabId() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function invalidateDownstreamAfterStepRestart(step, options = {}) {
|
||||
events.invalidations.push({ step, options });
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
await runAutoSequenceFromStep(1, {
|
||||
targetRun: 1,
|
||||
totalRuns: 1,
|
||||
attemptRuns: 1,
|
||||
continued: false,
|
||||
});
|
||||
return { events, currentState, error: null };
|
||||
} catch (error) {
|
||||
return { events, currentState, error: error.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
|
||||
assert.match(result.error, /SIGNUP_USER_ALREADY_EXISTS::/);
|
||||
assert.deepStrictEqual(result.events.invalidations, []);
|
||||
assert.deepStrictEqual(result.events.steps, [1, 2, 3, 4]);
|
||||
assert.equal(result.events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), false);
|
||||
});
|
||||
|
||||
@@ -100,18 +100,42 @@ test('account run history helper upgrades old records, keeps stopped items and s
|
||||
assert.equal(fetchCalled, false);
|
||||
assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: false, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), false);
|
||||
assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: true, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true);
|
||||
const stoppedRecord = helpers.buildAccountRunHistoryRecord({ email: 'a@b.com', password: 'x' }, 'stopped', 'stop');
|
||||
const stoppedRecord = helpers.buildAccountRunHistoryRecord(
|
||||
{ email: 'a@b.com', password: 'x' },
|
||||
'step7_stopped',
|
||||
'步骤 7 已被用户停止'
|
||||
);
|
||||
assert.equal(stoppedRecord.recordId, 'a@b.com');
|
||||
assert.equal(stoppedRecord.email, 'a@b.com');
|
||||
assert.equal(stoppedRecord.password, 'x');
|
||||
assert.equal(stoppedRecord.finalStatus, 'stopped');
|
||||
assert.equal(stoppedRecord.retryCount, 0);
|
||||
assert.equal(stoppedRecord.failureLabel, '流程已停止');
|
||||
assert.equal(stoppedRecord.failureDetail, 'stop');
|
||||
assert.equal(stoppedRecord.failedStep, null);
|
||||
assert.equal(stoppedRecord.failureLabel, '步骤 7 停止');
|
||||
assert.equal(stoppedRecord.failureDetail, '步骤 7 已被用户停止');
|
||||
assert.equal(stoppedRecord.failedStep, 7);
|
||||
assert.equal(stoppedRecord.source, 'manual');
|
||||
assert.equal(stoppedRecord.autoRunContext, null);
|
||||
assert.ok(stoppedRecord.finishedAt);
|
||||
|
||||
const genericStoppedRecord = helpers.buildAccountRunHistoryRecord({ email: 'stop@b.com', password: 'y' }, 'stopped', 'stop');
|
||||
assert.equal(genericStoppedRecord.failureLabel, '流程已停止');
|
||||
assert.equal(genericStoppedRecord.failedStep, null);
|
||||
|
||||
const normalizedStoppedRecord = helpers.normalizeAccountRunHistoryRecord({
|
||||
recordId: 'legacy-stop@example.com',
|
||||
email: 'legacy-stop@example.com',
|
||||
password: 'secret',
|
||||
finalStatus: 'stopped',
|
||||
finishedAt: '2026-04-17T00:12:00.000Z',
|
||||
retryCount: 0,
|
||||
failureLabel: '流程已停止',
|
||||
failureDetail: '步骤 7 已被用户停止。',
|
||||
failedStep: 7,
|
||||
source: 'manual',
|
||||
autoRunContext: null,
|
||||
});
|
||||
assert.equal(normalizedStoppedRecord.failureLabel, '步骤 7 停止');
|
||||
assert.equal(normalizedStoppedRecord.failedStep, 7);
|
||||
});
|
||||
|
||||
test('account run history helper clears persisted records and syncs full snapshot payload to local helper', async () => {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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('throwIfStopped rethrows an explicit stop error even when stopRequested has been cleared', () => {
|
||||
const api = new Function(`
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
let stopRequested = false;
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
return {
|
||||
run(error) {
|
||||
throwIfStopped(error);
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.throws(
|
||||
() => api.run(new Error('流程已被用户停止。')),
|
||||
/流程已被用户停止。/
|
||||
);
|
||||
});
|
||||
|
||||
test('executeStep reuses the active top-level auth chain instead of starting a duplicate step', async () => {
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
|
||||
let activeTopLevelAuthChainExecution = null;
|
||||
let stopRequested = false;
|
||||
let releaseStep8 = null;
|
||||
const events = {
|
||||
logs: [],
|
||||
statusCalls: [],
|
||||
registryCalls: [],
|
||||
};
|
||||
const state = {
|
||||
stepStatuses: {},
|
||||
};
|
||||
|
||||
async function addLog(message, level = 'info') {
|
||||
events.logs.push({ message, level });
|
||||
}
|
||||
async function setStepStatus(step, status) {
|
||||
state.stepStatuses[step] = status;
|
||||
events.statusCalls.push({ step, status });
|
||||
}
|
||||
async function humanStepDelay() {}
|
||||
async function getState() {
|
||||
return {
|
||||
flowStartTime: null,
|
||||
stepStatuses: { ...state.stepStatuses },
|
||||
};
|
||||
}
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
async function appendManualAccountRunRecordIfNeeded() {}
|
||||
function isTerminalSecurityBlockedError() {
|
||||
return false;
|
||||
}
|
||||
async function handleCloudflareSecurityBlocked() {}
|
||||
function doesStepUseCompletionSignal() {
|
||||
return false;
|
||||
}
|
||||
function isRetryableContentScriptTransportError() {
|
||||
return false;
|
||||
}
|
||||
const stepRegistry = {
|
||||
async executeStep(step) {
|
||||
events.registryCalls.push(step);
|
||||
if (step === 8) {
|
||||
await new Promise((resolve) => {
|
||||
releaseStep8 = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
${extractFunction('isAuthChainStep')}
|
||||
${extractFunction('acquireTopLevelAuthChainExecution')}
|
||||
${extractFunction('executeStep')}
|
||||
|
||||
return {
|
||||
executeStep,
|
||||
releaseStep8() {
|
||||
if (releaseStep8) {
|
||||
releaseStep8();
|
||||
}
|
||||
},
|
||||
snapshot() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const firstRun = api.executeStep(8);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const duplicateRun = api.executeStep(7);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
api.releaseStep8();
|
||||
|
||||
await firstRun;
|
||||
await duplicateRun;
|
||||
|
||||
const events = api.snapshot();
|
||||
assert.deepStrictEqual(events.registryCalls, [8]);
|
||||
assert.deepStrictEqual(events.statusCalls, [
|
||||
{ step: 8, status: 'running' },
|
||||
]);
|
||||
assert.ok(events.logs.some(({ message }) => /复用当前授权链/.test(message)));
|
||||
});
|
||||
|
||||
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;
|
||||
${extractFunction('normalizeOAuthFlowDeadlineAt')}
|
||||
${extractFunction('normalizeOAuthFlowSourceUrl')}
|
||||
${extractFunction('getOAuthFlowRemainingMs')}
|
||||
${extractFunction('getOAuthFlowStepTimeoutMs')}
|
||||
return {
|
||||
getOAuthFlowStepTimeoutMs,
|
||||
};
|
||||
`)();
|
||||
|
||||
const timeoutMs = await api.getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: 8,
|
||||
actionLabel: '登录验证码流程',
|
||||
state: {
|
||||
oauthUrl: 'https://oauth.example/current',
|
||||
oauthFlowDeadlineAt: Date.now() + 1200,
|
||||
oauthFlowDeadlineSourceUrl: 'https://oauth.example/old',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(timeoutMs, 15000);
|
||||
});
|
||||
@@ -0,0 +1,621 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const backgroundSource = fs.readFileSync('background.js', 'utf8');
|
||||
|
||||
function extractFunction(source, 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 createMockResponse(ok, status, payload) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
async json() {
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('background imports contribution oauth module and keeps contribution runtime out of persisted settings', () => {
|
||||
const persistedStart = backgroundSource.indexOf('const PERSISTED_SETTING_DEFAULTS = {');
|
||||
const persistedEnd = backgroundSource.indexOf('const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);');
|
||||
const defaultStateStart = backgroundSource.indexOf('const DEFAULT_STATE = {');
|
||||
const defaultStateEnd = backgroundSource.indexOf('async function getState()');
|
||||
|
||||
const persistedBlock = backgroundSource.slice(persistedStart, persistedEnd);
|
||||
const defaultStateBlock = backgroundSource.slice(defaultStateStart, defaultStateEnd);
|
||||
|
||||
assert.match(backgroundSource, /background\/contribution-oauth\.js/);
|
||||
assert.doesNotMatch(persistedBlock, /contributionSessionId|contributionAuthUrl|contributionCallbackUrl|contributionStatus/);
|
||||
assert.match(defaultStateBlock, /contributionMode:\s*false|CONTRIBUTION_RUNTIME_DEFAULTS/);
|
||||
});
|
||||
|
||||
test('contribution oauth module exposes a factory', () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async () => createMockResponse(true, 200, { ok: true })
|
||||
);
|
||||
|
||||
assert.equal(typeof api?.createContributionOAuthManager, 'function');
|
||||
assert.equal(Array.isArray(api?.RUNTIME_KEYS), true);
|
||||
});
|
||||
|
||||
test('buildContributionModeState preserves active contribution runtime while forcing CPA mode', () => {
|
||||
const bundle = extractFunction(backgroundSource, 'buildContributionModeState');
|
||||
|
||||
const api = new Function(`
|
||||
const DEFAULT_STATE = { panelMode: 'cpa' };
|
||||
const CONTRIBUTION_RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
};
|
||||
const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);
|
||||
${bundle}
|
||||
return { buildContributionModeState };
|
||||
`)();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
api.buildContributionModeState(true, {
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}, {
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionStatus: 'waiting',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
}),
|
||||
{
|
||||
contributionMode: true,
|
||||
contributionModeExpected: true,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: 'waiting',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'cpa',
|
||||
customPassword: '',
|
||||
accountRunHistoryTextEnabled: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
api.buildContributionModeState(false, {
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}, {
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionStatus: 'waiting',
|
||||
}),
|
||||
{
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('resetState preserves contribution runtime across reset', () => {
|
||||
assert.match(backgroundSource, /CONTRIBUTION_RUNTIME_KEYS/);
|
||||
assert.match(backgroundSource, /const contributionModeState = buildContributionModeState/);
|
||||
assert.match(backgroundSource, /\.\.\.contributionModeState/);
|
||||
});
|
||||
|
||||
test('message router handles contribution mode, start flow, and status polling messages', async () => {
|
||||
const source = fs.readFileSync('background/message-router.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
|
||||
|
||||
const calls = [];
|
||||
const router = api.createMessageRouter({
|
||||
ensureManualInteractionAllowed: async () => ({
|
||||
stepStatuses: { 1: 'pending', 2: 'completed' },
|
||||
contributionMode: true,
|
||||
}),
|
||||
pollContributionStatus: async (options) => {
|
||||
calls.push({ type: 'poll', options });
|
||||
return { contributionStatus: 'waiting' };
|
||||
},
|
||||
setContributionMode: async (enabled) => {
|
||||
calls.push({ type: 'toggle', enabled });
|
||||
return {
|
||||
contributionMode: Boolean(enabled),
|
||||
panelMode: 'cpa',
|
||||
};
|
||||
},
|
||||
startContributionFlow: async (options) => {
|
||||
calls.push({ type: 'start', options });
|
||||
return {
|
||||
contributionMode: true,
|
||||
contributionSessionId: 'session-001',
|
||||
contributionStatus: 'started',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const enableResponse = await router.handleMessage({
|
||||
type: 'SET_CONTRIBUTION_MODE',
|
||||
payload: { enabled: true },
|
||||
});
|
||||
const startResponse = await router.handleMessage({
|
||||
type: 'START_CONTRIBUTION_FLOW',
|
||||
payload: { nickname: '阿青', qq: '123456' },
|
||||
});
|
||||
const pollResponse = await router.handleMessage({
|
||||
type: 'POLL_CONTRIBUTION_STATUS',
|
||||
payload: { reason: 'test_poll' },
|
||||
});
|
||||
|
||||
assert.equal(enableResponse.ok, true);
|
||||
assert.equal(startResponse.ok, true);
|
||||
assert.equal(pollResponse.ok, true);
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'toggle', enabled: true },
|
||||
{ type: 'start', options: { nickname: '阿青', qq: '123456' } },
|
||||
{ type: 'poll', options: { reason: 'test_poll' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('message router re-syncs contribution mode before AUTO_RUN when sidepanel payload marks contributionMode=true', async () => {
|
||||
const source = fs.readFileSync('background/message-router.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
|
||||
|
||||
const calls = [];
|
||||
const router = api.createMessageRouter({
|
||||
clearStopRequest: () => {},
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getState: async () => ({
|
||||
contributionMode: false,
|
||||
stepStatuses: {},
|
||||
}),
|
||||
normalizeRunCount: (value) => Number(value) || 1,
|
||||
setContributionMode: async (enabled) => {
|
||||
calls.push({ type: 'toggle', enabled });
|
||||
return { contributionMode: true };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
calls.push({ type: 'setState', updates });
|
||||
},
|
||||
startAutoRunLoop: (totalRuns, options) => {
|
||||
calls.push({ type: 'startAutoRunLoop', totalRuns, options });
|
||||
},
|
||||
});
|
||||
|
||||
const response = await router.handleMessage({
|
||||
type: 'AUTO_RUN',
|
||||
payload: {
|
||||
totalRuns: 2,
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
contributionMode: true,
|
||||
contributionNickname: '阿青',
|
||||
contributionQq: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.ok, true);
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'toggle', enabled: true },
|
||||
{ type: 'setState', updates: { contributionNickname: '阿青', contributionQq: '123456' } },
|
||||
{ type: 'setState', updates: { autoRunSkipFailures: true } },
|
||||
{ type: 'startAutoRunLoop', totalRuns: 2, options: { autoRunSkipFailures: true, mode: 'restart' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('account run history snapshot sync is disabled in contribution mode', () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
|
||||
|
||||
const helpers = api.createAccountRunHistoryHelpers({
|
||||
addLog: async () => {},
|
||||
buildLocalHelperEndpoint: (baseUrl, path) => `${baseUrl}${path}`,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({}),
|
||||
set: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getState: async () => ({}),
|
||||
normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
helpers.shouldSyncAccountRunHistorySnapshot({
|
||||
contributionMode: true,
|
||||
accountRunHistoryTextEnabled: true,
|
||||
accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
helpers.shouldSyncAccountRunHistorySnapshot({
|
||||
contributionMode: false,
|
||||
accountRunHistoryTextEnabled: true,
|
||||
accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('contribution oauth manager starts session, opens auth url, submits callback, and continues polling final status', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const fetchCalls = [];
|
||||
const tabCalls = [];
|
||||
const closeCallbackCalls = [];
|
||||
let statusPollCount = 0;
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
};
|
||||
const broadcasts = [];
|
||||
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (String(url).endsWith('/start')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
state: 'oauth-state-001',
|
||||
auth_url: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
message: '登录地址已生成',
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/status?')) {
|
||||
statusPollCount += 1;
|
||||
if (statusPollCount === 1) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'waiting',
|
||||
message: '等待提交回调。',
|
||||
});
|
||||
}
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'processing',
|
||||
message: '回调地址已提交给 CPA,正在等待结果确认。',
|
||||
});
|
||||
}
|
||||
if (String(url).endsWith('/submit-callback')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'processing',
|
||||
message: '回调地址已提交给 CPA,正在等待结果确认。',
|
||||
});
|
||||
}
|
||||
return createMockResponse(true, 200, { ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate(updates) {
|
||||
broadcasts.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
async create(payload) {
|
||||
tabCalls.push(payload);
|
||||
return { id: 88, url: payload.url };
|
||||
},
|
||||
async update() {
|
||||
return null;
|
||||
},
|
||||
onUpdated: { addListener() {} },
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
closeLocalhostCallbackTabs: async (callbackUrl) => {
|
||||
closeCallbackCalls.push(callbackUrl);
|
||||
},
|
||||
getState: async () => currentState,
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
});
|
||||
|
||||
const startedState = await manager.startContributionFlow();
|
||||
assert.equal(startedState.contributionSessionId, 'session-001');
|
||||
assert.equal(startedState.contributionAuthState, 'oauth-state-001');
|
||||
assert.equal(startedState.contributionStatus, 'waiting');
|
||||
assert.equal(startedState.contributionAuthTabId, 88);
|
||||
assert.equal(tabCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\/start$/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"nickname":"user@example\.com"/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"qq":""/);
|
||||
assert.match(fetchCalls[1].url, /\/status\?/);
|
||||
|
||||
const callbackState = await manager.handleCapturedCallback(
|
||||
'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001',
|
||||
{ source: 'test' }
|
||||
);
|
||||
|
||||
assert.equal(callbackState.contributionCallbackUrl, 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001');
|
||||
assert.equal(callbackState.contributionCallbackStatus, 'submitted');
|
||||
assert.equal(callbackState.contributionStatus, 'processing');
|
||||
assert.equal(closeCallbackCalls[0], 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001');
|
||||
assert.ok(fetchCalls.some((call) => String(call.url).endsWith('/submit-callback')));
|
||||
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'captured'));
|
||||
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'submitted'));
|
||||
});
|
||||
|
||||
test('contribution oauth manager accepts localhost callback urls that contain error and state', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async () => createMockResponse(true, 200, { ok: true })
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
chrome: {
|
||||
tabs: {
|
||||
onUpdated: { addListener() {} },
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
getState: async () => ({}),
|
||||
setState: async () => ({}),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
manager.isContributionCallbackUrl(
|
||||
'http://localhost:1455/auth/callback?error=access_denied&state=oauth-state-001',
|
||||
{ contributionAuthState: 'oauth-state-001' }
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshOAuthUrlBeforeStep6 uses contribution oauth session instead of panel bridge in contribution mode', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
|
||||
const calls = [];
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { refreshOAuthUrlBeforeStep6 };
|
||||
`)();
|
||||
|
||||
globalThis.addLog = async (message) => {
|
||||
calls.push({ type: 'log', message });
|
||||
};
|
||||
globalThis.contributionOAuthManager = {
|
||||
async startContributionFlow(options) {
|
||||
calls.push({ type: 'contribution', options });
|
||||
return {
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.handleStepData = async (step, payload) => {
|
||||
calls.push({ type: 'step', step, payload });
|
||||
};
|
||||
globalThis.getPanelModeLabel = () => 'CPA';
|
||||
globalThis.requestOAuthUrlFromPanel = async () => {
|
||||
calls.push({ type: 'panel' });
|
||||
return { oauthUrl: 'https://panel.example.com/oauth' };
|
||||
};
|
||||
globalThis.LOG_PREFIX = '[test]';
|
||||
|
||||
const oauthUrl = await api.refreshOAuthUrlBeforeStep6({
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
assert.equal(oauthUrl, 'https://auth.example.com/oauth?state=oauth-state-001');
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'log', message: '步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...' },
|
||||
{
|
||||
type: 'contribution',
|
||||
options: {
|
||||
nickname: 'user@example.com',
|
||||
openAuthTab: false,
|
||||
stateOverride: {
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'step',
|
||||
step: 1,
|
||||
payload: {
|
||||
oauthUrl: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
delete globalThis.addLog;
|
||||
delete globalThis.contributionOAuthManager;
|
||||
delete globalThis.handleStepData;
|
||||
delete globalThis.getPanelModeLabel;
|
||||
delete globalThis.requestOAuthUrlFromPanel;
|
||||
delete globalThis.LOG_PREFIX;
|
||||
});
|
||||
|
||||
test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API path explicitly when contributionMode=false', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
|
||||
const calls = [];
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { refreshOAuthUrlBeforeStep6 };
|
||||
`)();
|
||||
|
||||
globalThis.addLog = async (message) => {
|
||||
calls.push({ type: 'log', message });
|
||||
};
|
||||
globalThis.contributionOAuthManager = {
|
||||
async startContributionFlow() {
|
||||
calls.push({ type: 'contribution' });
|
||||
return {
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=unexpected',
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.handleStepData = async (step, payload) => {
|
||||
calls.push({ type: 'step', step, payload });
|
||||
};
|
||||
globalThis.getPanelModeLabel = () => 'SUB2API';
|
||||
globalThis.requestOAuthUrlFromPanel = async () => {
|
||||
calls.push({ type: 'panel' });
|
||||
return { oauthUrl: 'https://panel.example.com/oauth' };
|
||||
};
|
||||
globalThis.LOG_PREFIX = '[test]';
|
||||
|
||||
const oauthUrl = await api.refreshOAuthUrlBeforeStep6({
|
||||
contributionMode: false,
|
||||
panelMode: 'sub2api',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
assert.equal(oauthUrl, 'https://panel.example.com/oauth');
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' },
|
||||
{ type: 'panel' },
|
||||
{
|
||||
type: 'step',
|
||||
step: 1,
|
||||
payload: {
|
||||
oauthUrl: 'https://panel.example.com/oauth',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
delete globalThis.addLog;
|
||||
delete globalThis.contributionOAuthManager;
|
||||
delete globalThis.handleStepData;
|
||||
delete globalThis.getPanelModeLabel;
|
||||
delete globalThis.requestOAuthUrlFromPanel;
|
||||
delete globalThis.LOG_PREFIX;
|
||||
});
|
||||
|
||||
test('executeStep10 blocks silent fallback when contributionModeExpected=true but contributionMode=false', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'executeStep10');
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { executeStep10 };
|
||||
`)();
|
||||
|
||||
globalThis.executeContributionStep10 = async () => ({ ok: true });
|
||||
globalThis.step10Executor = {
|
||||
async executeStep10() {
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => api.executeStep10({
|
||||
contributionModeExpected: true,
|
||||
contributionMode: false,
|
||||
}),
|
||||
/步骤 10:当前自动流程预期使用贡献模式/
|
||||
);
|
||||
|
||||
delete globalThis.executeContributionStep10;
|
||||
delete globalThis.step10Executor;
|
||||
});
|
||||
@@ -406,7 +406,10 @@ return {
|
||||
});
|
||||
|
||||
test('resetState preserves LuckMail session config, used map, and preserve tag cache while clearing runtime purchase state', async () => {
|
||||
const bundle = extractFunction('resetState');
|
||||
const bundle = [
|
||||
extractFunction('buildContributionModeState'),
|
||||
extractFunction('resetState'),
|
||||
].join('\n');
|
||||
|
||||
const factory = new Function([
|
||||
'let cleared = false;',
|
||||
@@ -418,6 +421,7 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
|
||||
" luckmailBaseUrl: 'https://mails.luckyous.com',",
|
||||
" luckmailEmailType: 'ms_graph',",
|
||||
" luckmailDomain: '',",
|
||||
" panelMode: 'cpa',",
|
||||
' luckmailUsedPurchases: {},',
|
||||
' luckmailPreserveTagId: 0,',
|
||||
" luckmailPreserveTagName: '保留',",
|
||||
@@ -425,6 +429,21 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
|
||||
" currentLuckmailMailCursor: { messageId: 'stale' },",
|
||||
' email: null,',
|
||||
'};',
|
||||
'const CONTRIBUTION_RUNTIME_DEFAULTS = {',
|
||||
' contributionMode: false,',
|
||||
" contributionSessionId: '',",
|
||||
" contributionAuthUrl: '',",
|
||||
" contributionAuthState: '',",
|
||||
" contributionCallbackUrl: '',",
|
||||
" contributionStatus: '',",
|
||||
" contributionStatusMessage: '',",
|
||||
' contributionLastPollAt: 0,',
|
||||
" contributionCallbackStatus: 'idle',",
|
||||
" contributionCallbackMessage: '',",
|
||||
' contributionAuthOpenedAt: 0,',
|
||||
' contributionAuthTabId: 0,',
|
||||
'};',
|
||||
'const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);',
|
||||
'function normalizeLuckmailBaseUrl(value) {',
|
||||
" const normalized = String(value || '').trim() || 'https://mails.luckyous.com';",
|
||||
" return normalized.replace(/\\/$/, '');",
|
||||
|
||||
@@ -15,3 +15,9 @@ test('panel bridge module exposes a factory', () => {
|
||||
|
||||
assert.equal(typeof api?.createPanelBridge, 'function');
|
||||
});
|
||||
|
||||
test('panel bridge requests oauth url with step 7 log label payload', () => {
|
||||
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
||||
assert.match(source, /logStep:\s*7/);
|
||||
assert.doesNotMatch(source, /logStep:\s*6/);
|
||||
});
|
||||
|
||||
@@ -172,6 +172,7 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as
|
||||
options: {
|
||||
step: 7,
|
||||
actionLabel: 'OAuth 登录并进入验证码页',
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -10,8 +10,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
const calls = {
|
||||
ensureReady: 0,
|
||||
ensureReadyOptions: [],
|
||||
executeStep7: 0,
|
||||
sleep: [],
|
||||
rerunStep7: 0,
|
||||
resolveOptions: null,
|
||||
setStates: [],
|
||||
};
|
||||
@@ -32,8 +31,8 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
calls.ensureReadyOptions.push(options || null);
|
||||
return { state: 'verification_page', displayedEmail: 'display.user@example.com' };
|
||||
},
|
||||
executeStep7: async () => {
|
||||
calls.executeStep7 += 1;
|
||||
rerunStep7ForStep8Recovery: async () => {
|
||||
calls.rerunStep7 += 1;
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 5000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 5000),
|
||||
@@ -59,9 +58,6 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async (ms) => {
|
||||
calls.sleep.push(ms);
|
||||
},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
@@ -79,8 +75,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
|
||||
assert.equal(calls.resolveOptions.beforeSubmit, undefined);
|
||||
assert.equal(calls.ensureReady, 1);
|
||||
assert.equal(calls.executeStep7, 0);
|
||||
assert.deepStrictEqual(calls.sleep, []);
|
||||
assert.equal(calls.rerunStep7, 0);
|
||||
assert.equal(calls.resolveOptions.filterAfterTimestamp, 123456);
|
||||
assert.equal(typeof calls.resolveOptions.getRemainingTimeMs, 'function');
|
||||
assert.equal(await calls.resolveOptions.getRemainingTimeMs({ actionLabel: '登录验证码流程' }), 5000);
|
||||
@@ -107,7 +102,7 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
|
||||
executeStep7: async () => {},
|
||||
rerunStep7ForStep8Recovery: async () => {},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
getMailConfig: () => ({
|
||||
@@ -130,7 +125,6 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async () => {},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
@@ -161,7 +155,7 @@ test('step 8 falls back to the run email when the verification page does not exp
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: '' }),
|
||||
executeStep7: async () => {},
|
||||
rerunStep7ForStep8Recovery: async () => {},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
getMailConfig: () => ({
|
||||
@@ -184,7 +178,6 @@ test('step 8 falls back to the run email when the verification page does not exp
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async () => {},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
@@ -201,7 +194,7 @@ test('step 8 falls back to the run email when the verification page does not exp
|
||||
|
||||
test('step 8 does not rerun step 7 when verification submit lands on add-phone', async () => {
|
||||
const calls = {
|
||||
executeStep7: 0,
|
||||
rerunStep7: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
@@ -217,8 +210,8 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone',
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
|
||||
executeStep7: async () => {
|
||||
calls.executeStep7 += 1;
|
||||
rerunStep7ForStep8Recovery: async () => {
|
||||
calls.rerunStep7 += 1;
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
@@ -242,7 +235,6 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone',
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async () => {},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
@@ -257,6 +249,6 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone',
|
||||
/add-phone/
|
||||
);
|
||||
|
||||
assert.equal(calls.executeStep7, 0);
|
||||
assert.equal(calls.rerunStep7, 0);
|
||||
assert.ok(!calls.logs.some(({ message }) => /准备从步骤 7 重新开始/.test(message)));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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('generic stopped record resolves to next unfinished step during execution gap', async () => {
|
||||
const bundle = [
|
||||
extractFunction('getRunningSteps'),
|
||||
extractFunction('inferStoppedRecordStep'),
|
||||
extractFunction('resolveAccountRunRecordStatusForStop'),
|
||||
extractFunction('extractStoppedStepFromRecordStatus'),
|
||||
extractFunction('resolveAccountRunRecordReasonForStop'),
|
||||
extractFunction('appendAndBroadcastAccountRunRecord'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const STEP_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const DEFAULT_STATE = {
|
||||
stepStatuses: Object.fromEntries(STEP_IDS.map((step) => [step, 'pending'])),
|
||||
};
|
||||
let captured = null;
|
||||
const accountRunHistoryHelpers = {
|
||||
appendAccountRunRecord: async (status, state, reason) => {
|
||||
captured = { status, state, reason };
|
||||
return { status, state, reason };
|
||||
},
|
||||
};
|
||||
async function broadcastAccountRunHistoryUpdate() {}
|
||||
async function getState() {
|
||||
return {};
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
inferStoppedRecordStep,
|
||||
resolveAccountRunRecordStatusForStop,
|
||||
resolveAccountRunRecordReasonForStop,
|
||||
appendAndBroadcastAccountRunRecord,
|
||||
getCaptured() {
|
||||
return captured;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const state = {
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
stepStatuses: {
|
||||
1: 'completed',
|
||||
2: 'completed',
|
||||
3: 'completed',
|
||||
4: 'completed',
|
||||
5: 'completed',
|
||||
6: 'completed',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(api.inferStoppedRecordStep(state), 7);
|
||||
assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'step7_stopped');
|
||||
assert.equal(api.resolveAccountRunRecordReasonForStop('step7_stopped', '流程已被用户停止。'), '步骤 7 已被用户停止。');
|
||||
assert.equal(
|
||||
api.resolveAccountRunRecordReasonForStop('step2_stopped', '步骤 2 已使用邮箱,流程尚未完成。'),
|
||||
'步骤 2 已停止:邮箱已设置,流程尚未完成。'
|
||||
);
|
||||
|
||||
await api.appendAndBroadcastAccountRunRecord('stopped', state, '流程已被用户停止。');
|
||||
assert.deepStrictEqual(api.getCaptured(), {
|
||||
status: 'step7_stopped',
|
||||
state,
|
||||
reason: '步骤 7 已被用户停止。',
|
||||
});
|
||||
});
|
||||
|
||||
test('requestStop appends a stopped record for the next unfinished step when no step is running', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeAutoRunSessionId'),
|
||||
extractFunction('clearCurrentAutoRunSessionId'),
|
||||
extractFunction('cleanupStep8NavigationListeners'),
|
||||
extractFunction('rejectPendingStep8'),
|
||||
extractFunction('getRunningSteps'),
|
||||
extractFunction('inferStoppedRecordStep'),
|
||||
extractFunction('requestStop'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let stopRequested = false;
|
||||
let autoRunActive = false;
|
||||
let autoRunCurrentRun = 0;
|
||||
let autoRunTotalRuns = 1;
|
||||
let autoRunAttemptRun = 0;
|
||||
let autoRunSessionId = 99;
|
||||
let webNavListener = null;
|
||||
let webNavCommittedListener = null;
|
||||
let step8TabUpdatedListener = null;
|
||||
let step8PendingReject = null;
|
||||
let resumeWaiter = null;
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
|
||||
const DEFAULT_STATE = {
|
||||
stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])),
|
||||
};
|
||||
const stepWaiters = new Map();
|
||||
const appended = [];
|
||||
const logs = [];
|
||||
const chrome = {
|
||||
webNavigation: {
|
||||
onBeforeNavigate: { removeListener() {} },
|
||||
onCommitted: { removeListener() {} },
|
||||
},
|
||||
tabs: {
|
||||
onUpdated: { removeListener() {} },
|
||||
},
|
||||
};
|
||||
|
||||
function cancelPendingCommands() {}
|
||||
function getPendingAutoRunTimerPlan() {
|
||||
return null;
|
||||
}
|
||||
async function cancelScheduledAutoRun() {}
|
||||
async function clearAutoRunTimerAlarm() {}
|
||||
function clearStopRequest() {
|
||||
stopRequested = false;
|
||||
}
|
||||
async function addLog(message, level) {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
async function broadcastStopToContentScripts() {}
|
||||
async function markRunningStepsStopped() {}
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function appendAndBroadcastAccountRunRecord(status, state, reason) {
|
||||
appended.push({ status, state, reason });
|
||||
return { status, state, reason };
|
||||
}
|
||||
async function getState() {
|
||||
return {
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
stepStatuses: {
|
||||
1: 'completed',
|
||||
2: 'completed',
|
||||
3: 'completed',
|
||||
4: 'completed',
|
||||
5: 'completed',
|
||||
6: 'completed',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
requestStop,
|
||||
snapshot() {
|
||||
return { appended, logs, stopRequested, autoRunSessionId };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await api.requestStop();
|
||||
const state = api.snapshot();
|
||||
|
||||
assert.deepStrictEqual(state.appended, [{
|
||||
status: 'stopped',
|
||||
state: {
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
stepStatuses: {
|
||||
1: 'completed',
|
||||
2: 'completed',
|
||||
3: 'completed',
|
||||
4: 'completed',
|
||||
5: 'completed',
|
||||
6: 'completed',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
},
|
||||
reason: '流程已被用户停止。',
|
||||
}]);
|
||||
assert.equal(state.autoRunSessionId, 0);
|
||||
assert.equal(state.stopRequested, true);
|
||||
});
|
||||
@@ -60,13 +60,22 @@ let refreshCalls = 0;
|
||||
const clickOrder = [];
|
||||
const readAndDeleteCalls = [];
|
||||
const seenCodes = new Set();
|
||||
const deletedMailIds = new Set();
|
||||
const baselineMail = { id: 'baseline', text: 'OpenAI newsletter without code' };
|
||||
const newMail = { id: 'new', text: 'OpenAI verification code 654321' };
|
||||
|
||||
function findMailItems() {
|
||||
if (state === 'detail') return [];
|
||||
if (state === 'baseline') return [baselineMail];
|
||||
return [baselineMail, newMail];
|
||||
const items = [];
|
||||
if (state === 'detail') return items;
|
||||
if (state === 'baseline' || state === 'with-new') {
|
||||
if (!deletedMailIds.has('baseline')) {
|
||||
items.push(baselineMail);
|
||||
}
|
||||
}
|
||||
if (state === 'with-new' && !deletedMailIds.has('new')) {
|
||||
items.push(newMail);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
@@ -99,7 +108,9 @@ async function sleepRandom() {}
|
||||
|
||||
async function returnToInbox() {
|
||||
clickOrder.push('inbox');
|
||||
state = 'baseline';
|
||||
if (state === 'detail') {
|
||||
state = 'baseline';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -113,6 +124,7 @@ async function refreshInbox() {
|
||||
|
||||
async function openMailAndDeleteAfterRead(item) {
|
||||
readAndDeleteCalls.push(item.id);
|
||||
deletedMailIds.add(item.id);
|
||||
return item.id === 'new' ? 'Your ChatGPT code is 654321' : 'No code here';
|
||||
}
|
||||
|
||||
@@ -142,7 +154,7 @@ return {
|
||||
|
||||
assert.equal(result.code, '654321');
|
||||
assert.deepEqual(api.getClickOrder(), ['inbox', 'refresh', 'inbox', 'refresh']);
|
||||
assert.deepEqual(api.getReadAndDeleteCalls(), ['new']);
|
||||
assert.deepEqual(api.getReadAndDeleteCalls(), ['baseline', 'new']);
|
||||
});
|
||||
|
||||
test('handlePollEmail ignores targetEmail and still tests any matching ChatGPT mail', async () => {
|
||||
@@ -419,12 +431,17 @@ test('deleteAllMailboxEmails selects all messages and clicks delete', async () =
|
||||
const calls = [];
|
||||
const selectAllControl = { kind: 'select-all' };
|
||||
const deleteButton = { kind: 'delete' };
|
||||
let mailboxCleared = false;
|
||||
|
||||
async function returnToInbox() {
|
||||
calls.push('inbox');
|
||||
return true;
|
||||
}
|
||||
|
||||
function findMailItems() {
|
||||
return mailboxCleared ? [] : [{ id: 'mail-1' }];
|
||||
}
|
||||
|
||||
function findSelectAllControl() {
|
||||
return selectAllControl;
|
||||
}
|
||||
@@ -444,11 +461,13 @@ function simulateClick(node) {
|
||||
}
|
||||
if (node === deleteButton) {
|
||||
calls.push('delete');
|
||||
mailboxCleared = true;
|
||||
return;
|
||||
}
|
||||
throw new Error('unexpected node');
|
||||
}
|
||||
|
||||
async function sleep() {}
|
||||
async function sleepRandom() {}
|
||||
|
||||
const console = { warn() {} };
|
||||
|
||||
@@ -207,7 +207,7 @@ test('account records manager supports filter chips and partial multi-select del
|
||||
finalStatus: 'stopped',
|
||||
finishedAt: '2026-04-17T04:28:00.000Z',
|
||||
retryCount: 1,
|
||||
failureLabel: '流程已停止',
|
||||
failureLabel: '步骤 7 停止',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -303,6 +303,7 @@ test('account records manager supports filter chips and partial multi-select del
|
||||
assert.doesNotMatch(list.innerHTML, /success@example\.com/);
|
||||
assert.match(list.innerHTML, /failed@example\.com/);
|
||||
assert.match(list.innerHTML, /stopped@example\.com/);
|
||||
assert.match(list.innerHTML, /步骤 7 停止/);
|
||||
|
||||
btnToggleAccountRecordsSelection.listeners.click();
|
||||
|
||||
|
||||
@@ -2,113 +2,17 @@ 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);
|
||||
}
|
||||
|
||||
test('sidepanel html contains contribution button in header', () => {
|
||||
test('sidepanel html keeps a single contribution mode button in header', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
assert.match(html, /id="btn-contribution-mode"/);
|
||||
assert.match(html, />贡献</);
|
||||
const matches = html.match(/id="btn-contribution-mode"/g) || [];
|
||||
|
||||
assert.equal(matches.length, 1);
|
||||
assert.match(html, /id="btn-contribution-mode"[^>]*title="进入贡献模式"/);
|
||||
});
|
||||
|
||||
test('openContributionUploadPage opens upload page in a new tab directly', async () => {
|
||||
const bundle = [
|
||||
extractFunction('openContributionUploadPage'),
|
||||
].join('\n');
|
||||
test('sidepanel source no longer keeps the legacy upload-page handler on the header contribution button', () => {
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
const api = new Function(`
|
||||
const calls = [];
|
||||
const CONTRIBUTION_UPLOAD_URL = 'https://apikey.qzz.io/';
|
||||
function isContributionButtonLocked() {
|
||||
return false;
|
||||
}
|
||||
function openExternalUrl(url) {
|
||||
calls.push({ type: 'open', url });
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
openContributionUploadPage,
|
||||
getCalls() {
|
||||
return calls;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.openContributionUploadPage();
|
||||
assert.equal(result, true);
|
||||
assert.deepStrictEqual(api.getCalls(), [
|
||||
{
|
||||
type: 'open',
|
||||
url: 'https://apikey.qzz.io/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('openContributionUploadPage blocks while flow is running', async () => {
|
||||
const bundle = [
|
||||
extractFunction('openContributionUploadPage'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const CONTRIBUTION_UPLOAD_URL = 'https://apikey.qzz.io/';
|
||||
function isContributionButtonLocked() {
|
||||
return true;
|
||||
}
|
||||
async function openConfirmModal() {
|
||||
throw new Error('should not open modal');
|
||||
}
|
||||
function openExternalUrl() {
|
||||
throw new Error('should not open url');
|
||||
}
|
||||
${bundle}
|
||||
return { openContributionUploadPage };
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.openContributionUploadPage(),
|
||||
/当前流程运行中/
|
||||
);
|
||||
assert.doesNotMatch(source, /openContributionUploadPage/);
|
||||
assert.doesNotMatch(source, /await openContributionUploadPage\(\)/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
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);
|
||||
}
|
||||
|
||||
function createClassList() {
|
||||
const values = new Set();
|
||||
return {
|
||||
add(name) {
|
||||
values.add(name);
|
||||
},
|
||||
remove(name) {
|
||||
values.delete(name);
|
||||
},
|
||||
toggle(name, force) {
|
||||
if (force === undefined) {
|
||||
if (values.has(name)) {
|
||||
values.delete(name);
|
||||
return false;
|
||||
}
|
||||
values.add(name);
|
||||
return true;
|
||||
}
|
||||
if (force) {
|
||||
values.add(name);
|
||||
return true;
|
||||
}
|
||||
values.delete(name);
|
||||
return false;
|
||||
},
|
||||
contains(name) {
|
||||
return values.has(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createElement(initial = {}) {
|
||||
return {
|
||||
disabled: Boolean(initial.disabled),
|
||||
hidden: Boolean(initial.hidden),
|
||||
value: initial.value || '',
|
||||
title: '',
|
||||
textContent: initial.textContent || '',
|
||||
listeners: {},
|
||||
attributes: {},
|
||||
classList: createClassList(),
|
||||
addEventListener(type, handler) {
|
||||
this.listeners[type] = handler;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('sidepanel html contains contribution mode runtime UI and loads the module before sidepanel bootstrap', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
const moduleIndex = html.indexOf('<script src="contribution-mode.js"></script>');
|
||||
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
|
||||
|
||||
assert.match(html, /id="btn-contribution-mode"/);
|
||||
assert.match(html, /id="contribution-mode-panel"/);
|
||||
assert.match(html, /id="contribution-oauth-status"/);
|
||||
assert.match(html, /id="contribution-callback-status"/);
|
||||
assert.match(html, /id="contribution-mode-summary"/);
|
||||
assert.match(html, /id="btn-start-contribution"/);
|
||||
assert.match(html, /id="btn-open-contribution-upload"/);
|
||||
assert.match(html, /id="btn-exit-contribution-mode"/);
|
||||
assert.match(html, /id="input-contribution-nickname"/);
|
||||
assert.match(html, /id="input-contribution-qq"/);
|
||||
assert.notEqual(moduleIndex, -1);
|
||||
assert.notEqual(sidepanelIndex, -1);
|
||||
assert.ok(moduleIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('collectSettingsPayload omits custom password and local sync settings in contribution mode', () => {
|
||||
const bundle = extractFunction('collectSettingsPayload');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = { contributionMode: true };
|
||||
let cloudflareDomainEditMode = false;
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
const selectCfDomain = { value: 'example.com' };
|
||||
const selectTempEmailDomain = { value: 'mail.example.com' };
|
||||
const selectPanelMode = { value: 'cpa' };
|
||||
const inputVpsUrl = { value: 'https://panel.example.com' };
|
||||
const inputVpsPassword = { value: 'panel-secret' };
|
||||
const inputSub2ApiUrl = { value: 'https://sub.example.com' };
|
||||
const inputSub2ApiEmail = { value: 'user@example.com' };
|
||||
const inputSub2ApiPassword = { value: 'sub-secret' };
|
||||
const inputSub2ApiGroup = { value: ' codex ' };
|
||||
const inputSub2ApiDefaultProxy = { value: ' proxy-a ' };
|
||||
const inputPassword = { value: 'Secret123!' };
|
||||
const selectMailProvider = { value: '163' };
|
||||
const selectEmailGenerator = { value: 'duck' };
|
||||
const checkboxAutoDeleteIcloud = { checked: true };
|
||||
const selectIcloudHostPreference = { value: 'auto' };
|
||||
const inputAccountRunHistoryTextEnabled = { checked: true };
|
||||
const inputAccountRunHistoryHelperBaseUrl = { value: 'http://127.0.0.1:17373' };
|
||||
const inputInbucketHost = { value: 'inbucket.local' };
|
||||
const inputInbucketMailbox = { value: 'demo' };
|
||||
const inputHotmailRemoteBaseUrl = { value: 'https://hotmail.example.com' };
|
||||
const inputHotmailLocalBaseUrl = { value: 'http://127.0.0.1:17373' };
|
||||
const inputLuckmailApiKey = { value: 'lk-api-key' };
|
||||
const inputLuckmailBaseUrl = { value: 'https://mails.example.com' };
|
||||
const selectLuckmailEmailType = { value: 'ms_graph' };
|
||||
const inputLuckmailDomain = { value: 'luckmail.example.com' };
|
||||
const inputTempEmailBaseUrl = { value: 'https://temp.example.com' };
|
||||
const inputTempEmailAdminAuth = { value: 'admin-secret' };
|
||||
const inputTempEmailCustomAuth = { value: 'custom-secret' };
|
||||
const inputTempEmailReceiveMailbox = { value: 'relay@example.com' };
|
||||
const inputAutoSkipFailures = { checked: false };
|
||||
const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' };
|
||||
const inputAutoDelayEnabled = { checked: true };
|
||||
const inputAutoDelayMinutes = { value: '30' };
|
||||
const inputAutoStepDelaySeconds = { value: '10' };
|
||||
const inputVerificationResendCount = { value: '6' };
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
|
||||
function getCloudflareDomainsFromState() { return { domains: ['example.com'], activeDomain: 'example.com' }; }
|
||||
function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); }
|
||||
function getCloudflareTempEmailDomainsFromState() { return { domains: ['mail.example.com'], activeDomain: 'mail.example.com' }; }
|
||||
function normalizeCloudflareTempEmailDomainValue(value) { return String(value || '').trim(); }
|
||||
function getSelectedLocalCpaStep9Mode() { return 'submit'; }
|
||||
function getSelectedMail2925Mode() { return 'provide'; }
|
||||
function normalizeAccountRunHistoryHelperBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function buildManagedAliasBaseEmailPayload() { return { gmailBaseEmail: '', mail2925BaseEmail: '', emailPrefix: '' }; }
|
||||
function getSelectedHotmailServiceMode() { return 'local'; }
|
||||
function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeLuckmailEmailType(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailReceiveMailboxValue(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.isFinite(Number(value)) ? Number(value) : fallback; }
|
||||
${bundle}
|
||||
return {
|
||||
collectSettingsPayload,
|
||||
setLatestState(nextState) { latestState = nextState; },
|
||||
};
|
||||
`)();
|
||||
|
||||
const contributionPayload = api.collectSettingsPayload();
|
||||
assert.equal('customPassword' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryTextEnabled' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryHelperBaseUrl' in contributionPayload, false);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('contribution mode manager enters mode, starts main auto flow, polls contribution status, and exits cleanly', async () => {
|
||||
const source = fs.readFileSync('sidepanel/contribution-mode.js', 'utf8');
|
||||
const windowObject = {};
|
||||
const timers = [];
|
||||
|
||||
const api = new Function('window', 'setTimeout', 'clearTimeout', `${source}; return window.SidepanelContributionMode;`)(
|
||||
windowObject,
|
||||
(handler) => {
|
||||
timers.push(handler);
|
||||
return timers.length;
|
||||
},
|
||||
(id) => {
|
||||
if (timers[id - 1]) {
|
||||
timers[id - 1] = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(typeof api?.createContributionModeManager, 'function');
|
||||
|
||||
let latestState = {
|
||||
contributionMode: false,
|
||||
panelMode: 'sub2api',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
let blocked = false;
|
||||
let appliedState = null;
|
||||
let statusState = null;
|
||||
let closeConfigMenuCount = 0;
|
||||
let closeAccountRecordsCount = 0;
|
||||
let contributionAutoRunStartCount = 0;
|
||||
let updatePanelModeCount = 0;
|
||||
let updateSyncUiCount = 0;
|
||||
let updateConfigMenuCount = 0;
|
||||
const toasts = [];
|
||||
const openedUrls = [];
|
||||
const sentMessages = [];
|
||||
|
||||
const dom = {
|
||||
btnConfigMenu: createElement(),
|
||||
btnContributionMode: createElement(),
|
||||
btnExitContributionMode: createElement(),
|
||||
btnOpenAccountRecords: createElement(),
|
||||
btnOpenContributionUpload: createElement(),
|
||||
btnStartContribution: createElement(),
|
||||
inputContributionNickname: createElement({ value: '贡献者昵称' }),
|
||||
inputContributionQq: createElement({ value: '123456' }),
|
||||
contributionCallbackStatus: createElement(),
|
||||
contributionModePanel: createElement({ hidden: true }),
|
||||
contributionModeSummary: createElement(),
|
||||
contributionModeText: createElement(),
|
||||
contributionOauthStatus: createElement(),
|
||||
rowAccountRunHistoryHelperBaseUrl: createElement(),
|
||||
rowAccountRunHistoryTextEnabled: createElement(),
|
||||
rowCustomPassword: createElement(),
|
||||
rowLocalCpaStep9Mode: createElement(),
|
||||
rowSub2ApiDefaultProxy: createElement(),
|
||||
rowSub2ApiEmail: createElement(),
|
||||
rowSub2ApiGroup: createElement(),
|
||||
rowSub2ApiPassword: createElement(),
|
||||
rowSub2ApiUrl: createElement(),
|
||||
rowVpsPassword: createElement(),
|
||||
rowVpsUrl: createElement(),
|
||||
selectPanelMode: createElement({ value: 'sub2api' }),
|
||||
};
|
||||
|
||||
const manager = api.createContributionModeManager({
|
||||
state: {
|
||||
getLatestState: () => latestState,
|
||||
},
|
||||
dom,
|
||||
helpers: {
|
||||
applySettingsState(nextState) {
|
||||
latestState = nextState;
|
||||
appliedState = nextState;
|
||||
},
|
||||
closeAccountRecordsPanel() {
|
||||
closeAccountRecordsCount += 1;
|
||||
},
|
||||
closeConfigMenu() {
|
||||
closeConfigMenuCount += 1;
|
||||
},
|
||||
getContributionNickname() {
|
||||
return latestState.email;
|
||||
},
|
||||
getContributionProfile() {
|
||||
return {
|
||||
nickname: dom.inputContributionNickname.value,
|
||||
qq: dom.inputContributionQq.value,
|
||||
};
|
||||
},
|
||||
isModeSwitchBlocked() {
|
||||
return blocked;
|
||||
},
|
||||
openConfirmModal: async () => {
|
||||
throw new Error('should not ask for confirmation before entering contribution mode');
|
||||
},
|
||||
openExternalUrl(url) {
|
||||
openedUrls.push(url);
|
||||
},
|
||||
showToast(message, type) {
|
||||
toasts.push({ message, type });
|
||||
},
|
||||
async startContributionAutoRun() {
|
||||
contributionAutoRunStartCount += 1;
|
||||
latestState = {
|
||||
...latestState,
|
||||
contributionSessionId: 'session-002',
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002',
|
||||
contributionAuthState: 'oauth-state-002',
|
||||
contributionStatus: 'started',
|
||||
contributionStatusMessage: '\u5df2\u751f\u6210\u767b\u5f55\u5730\u5740',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03',
|
||||
};
|
||||
return true;
|
||||
},
|
||||
updateAccountRunHistorySettingsUI() {
|
||||
updateSyncUiCount += 1;
|
||||
},
|
||||
updateConfigMenuControls() {
|
||||
updateConfigMenuCount += 1;
|
||||
},
|
||||
updatePanelModeUI() {
|
||||
updatePanelModeCount += 1;
|
||||
},
|
||||
updateStatusDisplay(nextState) {
|
||||
statusState = nextState;
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async (message) => {
|
||||
sentMessages.push(message);
|
||||
if (message.type === 'SET_CONTRIBUTION_MODE') {
|
||||
return {
|
||||
state: message.payload.enabled
|
||||
? {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: latestState.email,
|
||||
}
|
||||
: {
|
||||
contributionMode: false,
|
||||
panelMode: 'cpa',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: latestState.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (message.type === 'POLL_CONTRIBUTION_STATUS') {
|
||||
return {
|
||||
state: {
|
||||
...latestState,
|
||||
contributionStatus: 'processing',
|
||||
contributionStatusMessage: '已提交回调,等待 CPA 确认',
|
||||
contributionCallbackStatus: 'submitted',
|
||||
contributionCallbackMessage: '已提交回调',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (message.type === 'SET_CONTRIBUTION_PROFILE') {
|
||||
latestState = {
|
||||
...latestState,
|
||||
contributionNickname: message.payload.nickname,
|
||||
contributionQq: message.payload.qq,
|
||||
};
|
||||
return { state: latestState };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
constants: {
|
||||
contributionUploadUrl: 'https://apikey.qzz.io/',
|
||||
pollIntervalMs: 2500,
|
||||
},
|
||||
});
|
||||
|
||||
manager.render();
|
||||
assert.equal(dom.contributionModePanel.hidden, true);
|
||||
assert.equal(dom.btnContributionMode.disabled, false);
|
||||
|
||||
manager.bindEvents();
|
||||
await dom.btnContributionMode.listeners.click();
|
||||
|
||||
assert.equal(dom.contributionModePanel.hidden, false);
|
||||
assert.equal(dom.selectPanelMode.value, 'cpa');
|
||||
assert.equal(dom.selectPanelMode.disabled, true);
|
||||
assert.equal(dom.btnOpenAccountRecords.disabled, true);
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u672a\u751f\u6210\u767b\u5f55\u5730\u5740');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u7b49\u5f85\u56de\u8c03');
|
||||
assert.equal(dom.contributionModeSummary.textContent.length > 0, true);
|
||||
assert.equal(dom.btnContributionMode.classList.contains('is-active'), true);
|
||||
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), true);
|
||||
assert.ok(closeConfigMenuCount >= 1);
|
||||
assert.ok(closeAccountRecordsCount >= 1);
|
||||
assert.ok(updatePanelModeCount >= 1);
|
||||
assert.ok(updateSyncUiCount >= 1);
|
||||
assert.ok(updateConfigMenuCount >= 1);
|
||||
assert.equal(timers.length, 0);
|
||||
|
||||
dom.inputContributionNickname.value = '贡献者昵称';
|
||||
dom.inputContributionQq.value = '123456';
|
||||
|
||||
await dom.btnStartContribution.listeners.click();
|
||||
assert.equal(contributionAutoRunStartCount, 1);
|
||||
assert.equal(appliedState.contributionSessionId, '');
|
||||
assert.equal(latestState.contributionSessionId, 'session-002');
|
||||
assert.equal(latestState.contributionStatus, 'started');
|
||||
const contributionProfileMessage = sentMessages.find((message) => message.type === 'SET_CONTRIBUTION_PROFILE');
|
||||
assert.deepStrictEqual(contributionProfileMessage?.payload, { nickname: '贡献者昵称', qq: '123456' });
|
||||
assert.equal(timers.length > 0, true);
|
||||
|
||||
await manager.pollOnce({ reason: 'test_poll' });
|
||||
assert.equal(statusState.contributionStatus, 'processing');
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
|
||||
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85 CPA \u786e\u8ba4');
|
||||
|
||||
dom.btnOpenContributionUpload.listeners.click();
|
||||
assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io/']);
|
||||
|
||||
await dom.btnExitContributionMode.listeners.click();
|
||||
manager.render();
|
||||
assert.equal(dom.contributionModePanel.hidden, true);
|
||||
assert.equal(dom.btnContributionMode.classList.contains('is-active'), false);
|
||||
assert.equal(dom.selectPanelMode.disabled, false);
|
||||
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), false);
|
||||
assert.deepStrictEqual(
|
||||
sentMessages.map((message) => message.type),
|
||||
['SET_CONTRIBUTION_MODE', 'SET_CONTRIBUTION_PROFILE', 'POLL_CONTRIBUTION_STATUS', 'SET_CONTRIBUTION_MODE']
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
toasts.map((item) => item.message),
|
||||
['\u5df2\u8fdb\u5165\u8d21\u732e\u6a21\u5f0f\u3002', '\u8d21\u732e\u81ea\u52a8\u6d41\u7a0b\u5df2\u542f\u52a8\u3002', '\u5df2\u9000\u51fa\u8d21\u732e\u6a21\u5f0f\u3002']
|
||||
);
|
||||
|
||||
blocked = true;
|
||||
latestState = {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
contributionNickname: '贡献者昵称',
|
||||
contributionQq: '123456',
|
||||
contributionSessionId: 'session-002',
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002',
|
||||
contributionStatus: 'waiting',
|
||||
contributionStatusMessage: '\u7b49\u5f85\u6388\u6743\u5b8c\u6210',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03',
|
||||
};
|
||||
manager.render();
|
||||
assert.equal(dom.btnExitContributionMode.disabled, true);
|
||||
manager.stopPolling();
|
||||
});
|
||||
@@ -138,5 +138,57 @@ return {
|
||||
assert.deepStrictEqual(api.run(), {
|
||||
state: 'error',
|
||||
retryButton: { textContent: 'Try again' },
|
||||
userAlreadyExistsBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('signup verification state treats email-verification retry page as error instead of verification', () => {
|
||||
const api = new Function(`
|
||||
const location = {
|
||||
pathname: '/email-verification',
|
||||
};
|
||||
|
||||
function getAuthTimeoutErrorPageState(options) {
|
||||
return options.pathPatterns.some((pattern) => pattern.test(location.pathname))
|
||||
? { retryButton: { textContent: 'Try again' } }
|
||||
: null;
|
||||
}
|
||||
|
||||
function isStep5Ready() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVerificationPageStillVisible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSignupEmailAlreadyExistsPage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSignupPasswordInput() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSignupPasswordSubmitButton() {
|
||||
return null;
|
||||
}
|
||||
|
||||
${extractFunction('getSignupAuthRetryPathPatterns')}
|
||||
${extractFunction('getSignupPasswordTimeoutErrorPageState')}
|
||||
${extractFunction('isSignupPasswordErrorPage')}
|
||||
${extractFunction('inspectSignupVerificationState')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return inspectSignupVerificationState();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.deepStrictEqual(api.run(), {
|
||||
state: 'error',
|
||||
retryButton: { textContent: 'Try again' },
|
||||
userAlreadyExistsBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/signup-page.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => source.indexOf(marker))
|
||||
.find((index) => index >= 0);
|
||||
if (start < 0) {
|
||||
throw new Error(`missing function ${name}`);
|
||||
}
|
||||
|
||||
let parenDepth = 0;
|
||||
let signatureEnded = false;
|
||||
let braceStart = -1;
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (braceStart < 0) {
|
||||
throw new Error(`missing body for function ${name}`);
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < source.length; end += 1) {
|
||||
const ch = source[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
const bundle = extractFunction('step6LoginFromPasswordPage');
|
||||
|
||||
function createApi() {
|
||||
return new Function(`
|
||||
${bundle}
|
||||
return { step6LoginFromPasswordPage };
|
||||
`)();
|
||||
}
|
||||
|
||||
function cleanupGlobals() {
|
||||
delete globalThis.normalizeStep6Snapshot;
|
||||
delete globalThis.inspectLoginAuthState;
|
||||
delete globalThis.log;
|
||||
delete globalThis.step6SwitchToOneTimeCodeLogin;
|
||||
delete globalThis.createStep6RecoverableResult;
|
||||
delete globalThis.fillInput;
|
||||
delete globalThis.humanPause;
|
||||
delete globalThis.sleep;
|
||||
delete globalThis.triggerLoginSubmitAction;
|
||||
delete globalThis.waitForStep6PasswordSubmitTransition;
|
||||
}
|
||||
|
||||
test('step6LoginFromPasswordPage switches to one-time-code login when password is missing but switch trigger exists', async () => {
|
||||
const api = createApi();
|
||||
const logs = [];
|
||||
const snapshot = {
|
||||
state: 'password_page',
|
||||
passwordInput: { id: 'password' },
|
||||
switchTrigger: { id: 'otp' },
|
||||
};
|
||||
|
||||
globalThis.normalizeStep6Snapshot = (value) => value;
|
||||
globalThis.inspectLoginAuthState = () => snapshot;
|
||||
globalThis.log = (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
};
|
||||
globalThis.step6SwitchToOneTimeCodeLogin = async (value) => {
|
||||
assert.strictEqual(value, snapshot);
|
||||
return { step6Outcome: 'success', via: 'switch_to_one_time_code_login' };
|
||||
};
|
||||
globalThis.createStep6RecoverableResult = () => {
|
||||
throw new Error('should not create recoverable result when switch trigger exists');
|
||||
};
|
||||
globalThis.fillInput = () => {
|
||||
throw new Error('should not fill password when password is missing');
|
||||
};
|
||||
globalThis.humanPause = async () => {};
|
||||
globalThis.sleep = async () => {};
|
||||
globalThis.triggerLoginSubmitAction = async () => {};
|
||||
globalThis.waitForStep6PasswordSubmitTransition = async () => {
|
||||
throw new Error('should not submit password when password is missing');
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await api.step6LoginFromPasswordPage({ email: 'user@example.com', password: '' }, snapshot);
|
||||
|
||||
assert.deepStrictEqual(result, { step6Outcome: 'success', via: 'switch_to_one_time_code_login' });
|
||||
assert.deepStrictEqual(logs, [
|
||||
{ message: '步骤 7:当前未提供密码,改走一次性验证码登录。', level: 'warn' },
|
||||
]);
|
||||
} finally {
|
||||
cleanupGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('step6LoginFromPasswordPage returns a recoverable result when password is missing and no one-time-code trigger exists', async () => {
|
||||
const api = createApi();
|
||||
const snapshot = {
|
||||
state: 'password_page',
|
||||
passwordInput: { id: 'password' },
|
||||
switchTrigger: null,
|
||||
};
|
||||
|
||||
globalThis.normalizeStep6Snapshot = (value) => value;
|
||||
globalThis.inspectLoginAuthState = () => snapshot;
|
||||
globalThis.log = () => {};
|
||||
globalThis.step6SwitchToOneTimeCodeLogin = async () => {
|
||||
throw new Error('should not switch without a one-time-code trigger');
|
||||
};
|
||||
globalThis.createStep6RecoverableResult = (reason, stateSnapshot, details) => ({
|
||||
step6Outcome: 'recoverable',
|
||||
reason,
|
||||
stateSnapshot,
|
||||
...details,
|
||||
});
|
||||
globalThis.fillInput = () => {
|
||||
throw new Error('should not fill password when password is missing');
|
||||
};
|
||||
globalThis.humanPause = async () => {};
|
||||
globalThis.sleep = async () => {};
|
||||
globalThis.triggerLoginSubmitAction = async () => {};
|
||||
globalThis.waitForStep6PasswordSubmitTransition = async () => {
|
||||
throw new Error('should not submit password when password is missing');
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await api.step6LoginFromPasswordPage({ email: 'user@example.com', password: '' }, snapshot);
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
step6Outcome: 'recoverable',
|
||||
reason: 'missing_password_and_one_time_code_trigger',
|
||||
stateSnapshot: snapshot,
|
||||
message: '登录时未提供密码,且当前页面没有可用的一次性验证码登录入口。',
|
||||
});
|
||||
} finally {
|
||||
cleanupGlobals();
|
||||
}
|
||||
});
|
||||
@@ -117,10 +117,10 @@ return {
|
||||
|
||||
test('step 8 reruns step 7 when auth page enters login timeout retry state', async () => {
|
||||
const calls = {
|
||||
executeStep7: 0,
|
||||
rerunStep7: 0,
|
||||
ensureReady: 0,
|
||||
logs: [],
|
||||
sleeps: [],
|
||||
rerunOptions: [],
|
||||
resolveCalls: 0,
|
||||
};
|
||||
|
||||
@@ -142,8 +142,9 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
|
||||
}
|
||||
return { state: 'verification_page' };
|
||||
},
|
||||
executeStep7: async () => {
|
||||
calls.executeStep7 += 1;
|
||||
rerunStep7ForStep8Recovery: async (options) => {
|
||||
calls.rerunStep7 += 1;
|
||||
calls.rerunOptions.push(options || null);
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
@@ -167,9 +168,6 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async (ms) => {
|
||||
calls.sleeps.push(ms);
|
||||
},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
@@ -181,9 +179,13 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
});
|
||||
|
||||
assert.equal(calls.executeStep7, 1);
|
||||
assert.equal(calls.rerunStep7, 1);
|
||||
assert.equal(calls.ensureReady, 2);
|
||||
assert.equal(calls.resolveCalls, 1);
|
||||
assert.equal(calls.logs.some(({ message }) => /重新开始|重新发起/.test(message)), true);
|
||||
assert.deepStrictEqual(calls.sleeps, [3000]);
|
||||
assert.deepStrictEqual(calls.rerunOptions, [
|
||||
{
|
||||
logMessage: '步骤 8:认证页进入重试/超时报错状态,正在回到步骤 7 重新发起登录流程...',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -58,6 +58,8 @@ const helperBundle = [
|
||||
extractFunction(helperSource, 'cleanupStep8NavigationListeners'),
|
||||
extractFunction(helperSource, 'rejectPendingStep8'),
|
||||
extractFunction(helperSource, 'throwIfStep8SettledOrStopped'),
|
||||
extractFunction(helperSource, 'getRunningSteps'),
|
||||
extractFunction(helperSource, 'inferStoppedRecordStep'),
|
||||
extractFunction(helperSource, 'requestStop'),
|
||||
].join('\n');
|
||||
|
||||
@@ -75,6 +77,9 @@ let autoRunTotalRuns = 3;
|
||||
let autoRunAttemptRun = 4;
|
||||
let autoRunSessionId = 99;
|
||||
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
|
||||
const DEFAULT_STATE = {
|
||||
stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])),
|
||||
};
|
||||
const STEP8_CLICK_RETRY_DELAY_MS = 500;
|
||||
const STEP8_MAX_ROUNDS = 5;
|
||||
const STEP8_READY_WAIT_TIMEOUT_MS = 30000;
|
||||
@@ -137,6 +142,7 @@ async function addLog() {}
|
||||
async function broadcastStopToContentScripts() {}
|
||||
async function markRunningStepsStopped() {}
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function appendAndBroadcastAccountRunRecord() {}
|
||||
async function getState() {
|
||||
return { autoRunning: false };
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
|
||||
]);
|
||||
});
|
||||
|
||||
test('verification flow triggers 2925 mailbox cleanup only after code submission succeeds', async () => {
|
||||
test('verification flow clears 2925 mailbox before polling and after successful login code submission', async () => {
|
||||
const mailMessages = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
@@ -169,7 +169,73 @@ test('verification flow triggers 2925 mailbox cleanup only after code submission
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepStrictEqual(mailMessages, ['POLL_EMAIL', 'DELETE_ALL_EMAILS']);
|
||||
assert.deepStrictEqual(mailMessages, ['DELETE_ALL_EMAILS', 'POLL_EMAIL', 'DELETE_ALL_EMAILS']);
|
||||
});
|
||||
|
||||
test('verification flow clears 2925 mailbox before polling and after successful signup code submission', async () => {
|
||||
const mailMessages = [];
|
||||
|
||||
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 (_source, message) => {
|
||||
if (message.type === 'FILL_CODE') {
|
||||
return {};
|
||||
}
|
||||
if (message.type === 'RESEND_VERIFICATION_CODE') {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async (_mail, message) => {
|
||||
mailMessages.push(message.type);
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
return { code: '654321', emailTimestamp: 123 };
|
||||
}
|
||||
return { ok: true, deleted: true };
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await helpers.resolveVerificationStep(
|
||||
4,
|
||||
{
|
||||
email: 'user@example.com',
|
||||
mailProvider: '2925',
|
||||
lastSignupCode: null,
|
||||
},
|
||||
{ provider: '2925', label: '2925 邮箱' },
|
||||
{
|
||||
requestFreshCodeFirst: false,
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepStrictEqual(mailMessages, ['DELETE_ALL_EMAILS', 'POLL_EMAIL', 'DELETE_ALL_EMAILS']);
|
||||
});
|
||||
|
||||
test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => {
|
||||
@@ -301,6 +367,73 @@ test('verification flow caps mail polling timeout to the remaining oauth budget'
|
||||
assert.equal(mailPollCalls[0].payload.maxAttempts, 2);
|
||||
});
|
||||
|
||||
test('verification flow keeps 2925 mailbox polling at 15 refresh attempts even when oauth budget is smaller', async () => {
|
||||
const mailPollCalls = [];
|
||||
|
||||
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 (_source, message) => {
|
||||
if (message.type === 'FILL_CODE') {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async (_mail, message, options) => {
|
||||
mailPollCalls.push({
|
||||
type: message.type,
|
||||
payload: message.payload,
|
||||
options,
|
||||
});
|
||||
return { code: '654321', emailTimestamp: 123 };
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await helpers.resolveVerificationStep(
|
||||
8,
|
||||
{
|
||||
email: 'user@example.com',
|
||||
mailProvider: '2925',
|
||||
lastLoginCode: null,
|
||||
},
|
||||
{ provider: '2925', label: '2925 邮箱' },
|
||||
{
|
||||
getRemainingTimeMs: async () => 5000,
|
||||
resendIntervalMs: 0,
|
||||
disableTimeBudgetCap: true,
|
||||
}
|
||||
);
|
||||
|
||||
const pollCall = mailPollCalls.find((entry) => entry.type === 'POLL_EMAIL');
|
||||
assert.ok(pollCall);
|
||||
assert.equal(pollCall.payload.maxAttempts, 15);
|
||||
assert.ok(pollCall.options.timeoutMs >= 250000);
|
||||
});
|
||||
|
||||
test('verification flow keeps Hotmail request timestamp filtering on the first poll', async () => {
|
||||
const pollPayloads = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user