Merge branch 'master' into codex/upstream-pr

This commit is contained in:
dada2321
2026-04-20 16:04:13 +08:00
committed by GitHub
44 changed files with 4677 additions and 346 deletions
+47 -3
View File
@@ -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);
});
+163
View File
@@ -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);
});
+122
View File
@@ -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 () => {
+186
View File
@@ -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);
});
+621
View File
@@ -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: '步骤 7contributionMode=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: '步骤 7contributionMode=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;
});
+20 -1
View File
@@ -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 -18
View File
@@ -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)));
});
+237
View File
@@ -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);
});
+24 -5
View File
@@ -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();
+9 -105
View File
@@ -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\(\)/);
});
+476
View File
@@ -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,
});
});
+161
View File
@@ -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();
}
});
+11 -9
View File
@@ -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 重新发起登录流程...',
},
]);
});
+6
View File
@@ -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 };
}
+135 -2
View File
@@ -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 = [];