Merge branch 'dev' into master

This commit is contained in:
QLHazyCoder
2026-04-18 12:25:05 +08:00
23 changed files with 768 additions and 17 deletions
+167
View File
@@ -0,0 +1,167 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
test('auto-run controller does not retry add-phone failures even when auto retry is enabled', 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: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')),
isRestartCurrentAttemptError: () => false,
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;
throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone');
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(1, {
autoRunSkipFailures: true,
mode: 'restart',
});
assert.equal(events.runCalls, 1, 'add-phone fatal failure should stop before the next auto attempt starts');
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'add-phone fatal failure should not enter retrying phase');
assert.equal(events.accountRecords.length, 1, 'fatal add-phone should still persist a failed round record');
assert.equal(events.accountRecords[0].status, 'failed');
assert.match(events.accountRecords[0].reason, /add-phone/);
assert.ok(events.logs.some(({ message }) => /add-phone\/手机号页/.test(message)));
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
+17
View File
@@ -53,6 +53,7 @@ function extractFunction(name) {
}
const bundle = [
extractFunction('isAddPhoneAuthFailure'),
extractFunction('isAddPhoneAuthUrl'),
extractFunction('isAddPhoneAuthState'),
extractFunction('getPostStep6AutoRestartDecision'),
@@ -198,6 +199,22 @@ test('auto-run stops restarting once add-phone is detected', async () => {
assert.ok(result.events.logs.some(({ message }) => /进入 add-phone/.test(message)));
});
test('auto-run stops restarting on generic phone-page failure messages even without add-phone url', async () => {
const harness = createHarness({
failureStep: 9,
failureBudget: 1,
failureMessage: '步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。',
authState: { state: 'password_page', url: 'https://auth.openai.com/log-in' },
});
const result = await harness.runAndCaptureError();
assert.ok(result?.error);
assert.equal(result.events.invalidations.length, 0);
assert.deepStrictEqual(result.events.steps, [7, 8, 9]);
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => {
const harness = createHarness({
failureStep: 9,
@@ -61,6 +61,7 @@ const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL;
const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket';
const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
const VERIFICATION_RESEND_COUNT_MIN = 0;
@@ -109,4 +110,12 @@ return {
api.normalizeAccountRunHistoryHelperBaseUrl(''),
'http://127.0.0.1:17373'
);
assert.equal(
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ''),
'shadowrocket'
);
assert.equal(
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
'proxy-a'
);
});
+62
View File
@@ -139,3 +139,65 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
assert.equal(capturedOptions.beforeSubmit, undefined);
assert.equal(typeof capturedOptions.getRemainingTimeMs, 'function');
});
test('step 8 does not rerun step 7 when verification submit lands on add-phone', async () => {
const calls = {
executeStep7: 0,
logs: [],
};
const executor = api.createStep8Executor({
addLog: async (message, level = 'info') => {
calls.logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
executeStep7: async () => {
calls.executeStep7 += 1;
},
getOAuthFlowRemainingMs: async () => 8000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
getMailConfig: () => ({
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async () => {
throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone');
},
reuseOrCreateTab: async () => {},
setState: async () => {},
setStepStatus: async () => {},
shouldUseCustomRegistrationEmail: () => false,
sleepWithStop: async () => {},
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await assert.rejects(
() => executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
}),
/add-phone/
);
assert.equal(calls.executeStep7, 0);
assert.ok(!calls.logs.some(({ message }) => /准备从步骤 7 重新开始/.test(message)));
});
@@ -82,6 +82,7 @@ test('sidepanel html contains account records overlay and manager script', () =>
assert.match(html, /id="account-records-list"/);
assert.match(html, /id="account-records-stats"/);
assert.match(html, /id="btn-clear-account-records"/);
assert.match(html, /id="input-sub2api-default-proxy"/);
assert.notEqual(managerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
assert.ok(managerIndex < sidepanelIndex);
+178
View File
@@ -0,0 +1,178 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function createJsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(payload),
};
}
function createSub2ApiPanelContext(fetchCalls = []) {
const storage = new Map();
const documentElement = {
attrs: new Map(),
getAttribute(name) {
return this.attrs.get(name) || null;
},
setAttribute(name, value) {
this.attrs.set(name, String(value));
},
};
const context = {
URL,
console: { log() {} },
setTimeout() {},
document: { documentElement },
location: {
href: 'https://sub.example/admin/accounts',
origin: 'https://sub.example',
pathname: '/admin/accounts',
replace() {},
},
chrome: {
runtime: {
onMessage: { addListener() {} },
sendMessage: async () => ({
email: 'flow@example.com',
sub2apiDefaultProxyName: 'shadowrocket',
}),
},
},
localStorage: {
setItem(key, value) { storage.set(`local:${key}`, String(value)); },
removeItem(key) { storage.delete(`local:${key}`); },
},
sessionStorage: {
removeItem(key) { storage.delete(`session:${key}`); },
},
log() {},
reportComplete(step, payload) {
context.completed.push({ step, payload });
},
reportReady() {},
reportError() {},
resetStopState() {},
throwIfStopped() {},
isStopError() { return false; },
completed: [],
fetch: async (url, options = {}) => {
const parsed = new URL(url);
const body = options.body ? JSON.parse(options.body) : null;
fetchCalls.push({ path: parsed.pathname, search: parsed.search, method: options.method || 'GET', body });
if (parsed.pathname === '/api/v1/auth/login') {
return createJsonResponse({
code: 0,
data: {
access_token: 'admin-token',
refresh_token: 'refresh-admin',
expires_in: 3600,
user: { id: 1 },
},
});
}
if (parsed.pathname === '/api/v1/admin/groups/all') {
return createJsonResponse({
code: 0,
data: [{ id: 5, name: 'codex', platform: 'openai' }],
});
}
if (parsed.pathname === '/api/v1/admin/proxies/all') {
return createJsonResponse({
code: 0,
data: [{
id: 7,
name: 'shadowrocket',
protocol: 'socks5',
host: '127.0.0.1',
port: 1080,
status: 'active',
}],
});
}
if (parsed.pathname === '/api/v1/admin/openai/generate-auth-url') {
return createJsonResponse({
code: 0,
data: {
auth_url: 'https://auth.openai.com/oauth?state=oauth-state',
session_id: 'session-1',
state: 'oauth-state',
},
});
}
if (parsed.pathname === '/api/v1/admin/openai/exchange-code') {
return createJsonResponse({
code: 0,
data: {
access_token: 'openai-access',
refresh_token: 'openai-refresh',
expires_at: 1770000000,
email: 'flow@example.com',
},
});
}
if (parsed.pathname === '/api/v1/admin/accounts') {
return createJsonResponse({
code: 0,
data: { id: 11 },
});
}
return createJsonResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
},
};
vm.createContext(context);
vm.runInContext(fs.readFileSync('content/sub2api-panel.js', 'utf8'), context);
return context;
}
test('SUB2API step 1 selects the configured default proxy before generating OAuth URL', async () => {
const fetchCalls = [];
const context = createSub2ApiPanelContext(fetchCalls);
const result = await vm.runInContext(`
step1_generateOpenAiAuthUrl({
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
sub2apiDefaultProxyName: 'shadowrocket'
}, { report: false })
`, context);
const generateCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/generate-auth-url');
assert.equal(result.sub2apiProxyId, 7);
assert.equal(generateCall.body.proxy_id, 7);
});
test('SUB2API step 10 uses the same proxy for code exchange and account creation', async () => {
const fetchCalls = [];
const context = createSub2ApiPanelContext(fetchCalls);
await vm.runInContext(`
step9_submitOpenAiCallback({
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
sub2apiGroupId: 5,
sub2apiProxyId: 7
})
`, context);
const exchangeCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/exchange-code');
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
assert.equal(exchangeCall.body.proxy_id, 7);
assert.equal(createCall.body.proxy_id, 7);
assert.equal(createCall.body.group_ids[0], 5);
assert.equal(context.completed[0].step, 10);
});
+65
View File
@@ -109,6 +109,71 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
]);
});
test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => {
const events = [];
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]);
},
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') {
events.push(['submit', message.payload.code]);
return {
addPhonePage: true,
url: 'https://auth.openai.com/add-phone',
};
}
return {};
},
sendToMailContentScriptResilient: async () => ({
code: '654321',
emailTimestamp: 123,
}),
setState: async (payload) => {
events.push(['state', payload.lastLoginCode || payload.lastSignupCode]);
},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await assert.rejects(
() => helpers.resolveVerificationStep(
8,
{ email: 'user@example.com', lastLoginCode: null },
{ provider: 'qq', label: 'QQ 邮箱' },
{}
),
/验证码提交后页面进入手机号页面/
);
assert.deepStrictEqual(events, [
['submit', '654321'],
]);
});
test('verification flow caps mail polling timeout to the remaining oauth budget', async () => {
const mailPollCalls = [];