Merge origin/master into master

This commit is contained in:
sh2001sh
2026-04-21 16:19:42 +08:00
21 changed files with 942 additions and 88 deletions
+17 -1
View File
@@ -3,6 +3,11 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const mail2925Utils = require('../mail2925-utils.js');
test('background mail2925 session uses /login/ as relogin entry url', () => {
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
assert.match(source, /const MAIL2925_LOGIN_URL = 'https:\/\/2925\.com\/login\/';/);
});
test('ensureMail2925MailboxSession waits 3 seconds before and after opening login page on force relogin', async () => {
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
const globalScope = {};
@@ -17,6 +22,8 @@ test('ensureMail2925MailboxSession waits 3 seconds before and after opening logi
};
const events = {
sleeps: [],
openedUrls: [],
readyCalls: 0,
};
const manager = api.createMail2925SessionManager({
@@ -41,7 +48,13 @@ test('ensureMail2925MailboxSession waits 3 seconds before and after opening logi
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
requestStop: async () => {},
reuseOrCreateTab: async () => 1,
ensureContentScriptReadyOnTab: async () => {
events.readyCalls += 1;
},
reuseOrCreateTab: async (_source, url) => {
events.openedUrls.push(url);
return 1;
},
sendToMailContentScriptResilient: async () => ({ loggedIn: true }),
setPersistentSettings: async (payload) => {
currentState = { ...currentState, ...payload };
@@ -54,6 +67,7 @@ test('ensureMail2925MailboxSession waits 3 seconds before and after opening logi
},
throwIfStopped: () => {},
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
waitForTabUrlMatch: async () => ({ url: 'https://2925.com/login/' }),
});
await manager.ensureMail2925MailboxSession({
@@ -62,5 +76,7 @@ test('ensureMail2925MailboxSession waits 3 seconds before and after opening logi
actionLabel: '步骤 4:确认 2925 邮箱登录态',
});
assert.deepStrictEqual(events.openedUrls, ['https://2925.com/login/']);
assert.deepStrictEqual(events.sleeps, [3000, 3000]);
assert.equal(events.readyCalls, 1);
});
@@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/fetch-signup-code.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep4;`)(globalScope);
test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling', async () => {
let capturedOptions = null;
const realDateNow = Date.now;
Date.now = () => 700000;
const executor = api.createStep4Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {},
getMailConfig: () => ({
provider: '2925',
label: '2925 邮箱',
source: 'mail-2925',
url: 'https://2925.com',
}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
LUCKMAIL_PROVIDER: 'luckmail-api',
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
resolveVerificationStep: async (_step, _state, _mail, options) => {
capturedOptions = options;
},
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async () => ({}),
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
});
try {
await executor.executeStep4({
email: 'user@example.com',
password: 'secret',
mail2925UseAccountPool: false,
});
} finally {
Date.now = realDateNow;
}
assert.equal(capturedOptions.filterAfterTimestamp, 100000);
assert.equal(capturedOptions.resendIntervalMs, 0);
});
+13 -6
View File
@@ -89,8 +89,10 @@ test('step 8 submits login verification directly without replaying step 7', asyn
]);
});
test('step 8 disables resend interval for 2925 mailbox polling', async () => {
test('step 8 uses a fixed 10-minute lookback window and disables resend interval for 2925 mailbox polling', async () => {
let capturedOptions = null;
const realDateNow = Date.now;
Date.now = () => 900000;
const executor = api.createStep8Executor({
addLog: async () => {},
@@ -130,12 +132,17 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
throwIfStopped: () => {},
});
await executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
try {
await executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
} finally {
Date.now = realDateNow;
}
assert.equal(capturedOptions.filterAfterTimestamp, 300000);
assert.equal(capturedOptions.resendIntervalMs, 0);
assert.equal(capturedOptions.targetEmail, '');
assert.equal(capturedOptions.beforeSubmit, undefined);
@@ -0,0 +1,161 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('sidepanel/contribution-content-update-service.js', 'utf8');
function createContributionContentService(options = {}) {
const cache = new Map();
const windowObject = {};
let fetchCalls = 0;
const localStorage = {
getItem(key) {
return cache.has(key) ? cache.get(key) : null;
},
setItem(key, value) {
cache.set(key, String(value));
},
removeItem(key) {
cache.delete(key);
},
};
if (options.cachedSnapshot) {
cache.set(
'multipage-contribution-content-summary-v1',
JSON.stringify(options.cachedSnapshot)
);
}
const fetchImpl = options.fetchImpl || (async () => ({
ok: true,
async json() {
return {
ok: true,
items: [],
prompt_version: '',
has_visible_updates: false,
latest_updated_at: '',
latest_updated_at_display: '',
};
},
}));
const wrappedFetch = async (...args) => {
fetchCalls += 1;
return fetchImpl(...args);
};
const api = new Function(
'window',
'localStorage',
'fetch',
'AbortController',
'setTimeout',
'clearTimeout',
`${source}; return window.SidepanelContributionContentService;`
)(
windowObject,
localStorage,
wrappedFetch,
AbortController,
setTimeout,
clearTimeout
);
return {
api,
getFetchCalls() {
return fetchCalls;
},
};
}
test('getContentUpdateSnapshot returns a prompt version for visible contribution content updates', async () => {
const { api } = createContributionContentService({
fetchImpl: async () => ({
ok: true,
async json() {
return {
ok: true,
prompt_version: 'announcement:2026-04-21T12:00:00Z|tutorial:2026-04-21T12:05:00Z',
has_visible_updates: true,
latest_updated_at: '2026-04-21T12:05:00Z',
latest_updated_at_display: '2026-04-21 20:05',
items: [
{
slug: 'announcement',
title: '最新公告',
is_enabled: true,
has_content: true,
is_visible: true,
updated_at: '2026-04-21T12:00:00Z',
updated_at_display: '2026-04-21 20:00',
},
{
slug: 'tutorial',
title: '使用教程',
is_enabled: true,
has_content: true,
is_visible: true,
updated_at: '2026-04-21T12:05:00Z',
updated_at_display: '2026-04-21 20:05',
},
],
};
},
}),
});
const snapshot = await api.getContentUpdateSnapshot();
assert.equal(snapshot.status, 'update-available');
assert.equal(snapshot.promptVersion, 'announcement:2026-04-21T12:00:00Z|tutorial:2026-04-21T12:05:00Z');
assert.equal(snapshot.hasVisibleUpdates, true);
assert.equal(snapshot.latestUpdatedAt, '2026-04-21T12:05:00Z');
assert.equal(snapshot.items.length, 2);
assert.deepEqual(
snapshot.items.map((item) => [item.slug, item.isVisible]),
[['announcement', true], ['tutorial', true]]
);
});
test('getContentUpdateSnapshot falls back to cached snapshot when the live request fails', async () => {
const cachedSnapshot = {
status: 'update-available',
promptVersion: 'announcement:2026-04-20T00:00:00Z',
hasVisibleUpdates: true,
latestUpdatedAt: '2026-04-20T00:00:00Z',
latestUpdatedAtDisplay: '2026-04-20 08:00',
items: [
{
slug: 'announcement',
title: '站点公告',
isEnabled: true,
hasContent: true,
isVisible: true,
updatedAt: '2026-04-20T00:00:00Z',
updatedAtDisplay: '2026-04-20 08:00',
},
],
portalUrl: 'https://apikey.qzz.io',
apiUrl: 'https://apikey.qzz.io/api/content-summary',
checkedAt: Date.now() - 1000,
};
const { api, getFetchCalls } = createContributionContentService({
cachedSnapshot,
fetchImpl: async () => {
throw new Error('offline');
},
});
const snapshot = await api.getContentUpdateSnapshot();
assert.equal(getFetchCalls(), 1);
assert.equal(snapshot.fromCache, true);
assert.equal(snapshot.promptVersion, cachedSnapshot.promptVersion);
assert.equal(snapshot.errorMessage, 'offline');
assert.equal(snapshot.items[0].slug, 'announcement');
});
+96 -2
View File
@@ -56,7 +56,10 @@ function extractFunction(name) {
}
test('handlePollEmail establishes a baseline after opening from detail view and only picks mail from a later refresh', async () => {
const bundle = extractFunction('handlePollEmail');
const bundle = [
extractFunction('normalizeMinuteTimestamp'),
extractFunction('handlePollEmail'),
].join('\n');
const api = new Function(`
let state = 'detail';
@@ -162,7 +165,10 @@ return {
});
test('handlePollEmail ignores targetEmail and still tests any matching ChatGPT mail', async () => {
const bundle = extractFunction('handlePollEmail');
const bundle = [
extractFunction('normalizeMinuteTimestamp'),
extractFunction('handlePollEmail'),
].join('\n');
const api = new Function(`
let state = 'empty';
@@ -242,6 +248,94 @@ return {
assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-1']);
});
test('handlePollEmail only accepts 2925 mails inside the fixed lookback window', async () => {
const bundle = [
extractFunction('normalizeMinuteTimestamp'),
extractFunction('handlePollEmail'),
].join('\n');
const api = new Function(`
let state = 'ready';
const seenCodes = new Set();
const readAndDeleteCalls = [];
const oldMail = {
id: 'mail-old',
text: 'OpenAI verification code 111111',
timestamp: 1000,
};
const windowMail = {
id: 'mail-window',
text: 'OpenAI verification code 222222',
timestamp: 301000,
};
function findMailItems() {
return state === 'ready' ? [oldMail, windowMail] : [];
}
function getMailItemId(item) {
return item.id;
}
function getCurrentMailIds(items = []) {
return new Set(items.map((item) => item.id));
}
function parseMailItemTimestamp(item) {
return item.timestamp;
}
function matchesMailFilters(text) {
return /openai|verification/i.test(String(text || ''));
}
function getMailItemText(item) {
return item.text;
}
function extractVerificationCode(text) {
const match = String(text || '').match(/(\\d{6})/);
return match ? match[1] : null;
}
async function sleep() {}
async function sleepRandom() {}
async function returnToInbox() {
return true;
}
async function refreshInbox() {}
async function openMailAndDeleteAfterRead(item) {
readAndDeleteCalls.push(item.id);
return item.text;
}
async function ensureSeenCodesSession() {}
function persistSeenCodes() {}
function log() {}
${bundle}
return {
handlePollEmail,
getReadAndDeleteCalls() {
return readAndDeleteCalls.slice();
},
};
`)();
const result = await api.handlePollEmail(4, {
senderFilters: ['openai'],
subjectFilters: ['verification'],
maxAttempts: 1,
intervalMs: 1,
filterAfterTimestamp: 120000,
});
assert.equal(result.code, '222222');
assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-window']);
});
test('ensureSeenCodesSession resets tried codes only when a new verification step session starts', async () => {
const bundle = [
extractFunction('buildSeenCodeSessionKey'),
@@ -0,0 +1,131 @@
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 createApi({ refreshImpl } = {}) {
const bundle = extractFunction('startAutoRunFromCurrentSettings');
return new Function(`
const events = [];
const latestState = { contributionMode: false };
const inputAutoSkipFailures = { checked: false };
const inputContributionNickname = { value: 'tester' };
const inputContributionQq = { value: '123456' };
const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const btnAutoRun = { disabled: false, innerHTML: '' };
const inputRunCount = { disabled: false };
const chrome = {
runtime: {
async sendMessage(message) {
events.push({ type: 'send', message });
return { ok: true };
},
},
};
const console = {
warn(...args) {
events.push({ type: 'warn', args });
},
};
function getRunCountValue() { return 3; }
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
function shouldOfferAutoModeChoice() { return false; }
async function openAutoStartChoiceDialog() { throw new Error('should not be called'); }
function getFirstUnfinishedStep() { return 1; }
function getRunningSteps() { return []; }
function shouldWarnAutoRunFallbackRisk() { return false; }
function isAutoRunFallbackRiskPromptDismissed() { return false; }
async function openAutoRunFallbackRiskConfirmModal() { throw new Error('should not be called'); }
function setAutoRunFallbackRiskPromptDismissed() {}
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
async function refreshContributionContentHint() {
events.push({ type: 'refresh' });
${refreshImpl ? 'return (' + refreshImpl + ')();' : 'return null;'}
}
${bundle}
return {
startAutoRunFromCurrentSettings,
getEvents() {
return events;
},
};
`)();
}
test('startAutoRunFromCurrentSettings refreshes contribution content hint before starting auto run', async () => {
const api = createApi();
const result = await api.startAutoRunFromCurrentSettings();
assert.equal(result, true);
assert.deepEqual(
api.getEvents().map((entry) => entry.type),
['refresh', 'send']
);
assert.equal(api.getEvents()[1].message.type, 'AUTO_RUN');
});
test('startAutoRunFromCurrentSettings continues auto run when contribution content refresh fails', async () => {
const api = createApi({
refreshImpl: 'async () => { throw new Error("refresh failed"); }',
});
const result = await api.startAutoRunFromCurrentSettings();
const events = api.getEvents();
assert.equal(result, true);
assert.deepEqual(
events.map((entry) => entry.type),
['refresh', 'warn', 'send']
);
assert.match(String(events[1].args[0]), /Failed to refresh contribution content hint before auto run/);
assert.equal(events[2].message.type, 'AUTO_RUN');
});
@@ -5,10 +5,18 @@ const fs = require('node:fs');
test('sidepanel html keeps a single contribution mode button in header', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const matches = html.match(/id="btn-contribution-mode"/g) || [];
const serviceIndex = html.indexOf('<script src="contribution-content-update-service.js"></script>');
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
assert.equal(matches.length, 1);
assert.match(html, /id="btn-contribution-mode"[^>]*title="进入贡献模式并打开上传页"/);
assert.match(html, />贡献\/使用<\/button>/);
assert.match(html, /id="contribution-update-hint"/);
assert.match(html, /id="contribution-update-hint-text"/);
assert.match(html, /id="btn-dismiss-contribution-update-hint"/);
assert.notEqual(serviceIndex, -1);
assert.notEqual(sidepanelIndex, -1);
assert.ok(serviceIndex < sidepanelIndex);
});
test('sidepanel source no longer keeps the legacy upload-page handler on the header contribution button', () => {
+7 -8
View File
@@ -6,7 +6,7 @@ const source = fs.readFileSync('background/verification-flow.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope);
test('verification flow extends 2925 polling window', () => {
test('verification flow keeps 2925 polling cadence in the default payload', () => {
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
@@ -37,10 +37,8 @@ test('verification flow extends 2925 polling window', () => {
const step4Payload = helpers.getVerificationPollPayload(4, { email: 'user@example.com', mailProvider: '2925' });
const step8Payload = helpers.getVerificationPollPayload(8, { email: 'user@example.com', mailProvider: '2925' });
assert.equal(step4Payload.filterAfterTimestamp, 0);
assert.equal(step4Payload.maxAttempts, 15);
assert.equal(step4Payload.intervalMs, 15000);
assert.equal(step8Payload.filterAfterTimestamp, 0);
assert.equal(step8Payload.maxAttempts, 15);
assert.equal(step8Payload.intervalMs, 15000);
});
@@ -111,7 +109,7 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
]);
});
test('verification flow clears 2925 mailbox before polling and after successful login code submission', async () => {
test('verification flow skips 2925 mailbox preclear when using a fixed login mail window and still clears after success', async () => {
const mailMessages = [];
const helpers = api.createVerificationFlowHelpers({
@@ -164,15 +162,15 @@ test('verification flow clears 2925 mailbox before polling and after successful
lastLoginCode: null,
},
{ provider: '2925', label: '2925 邮箱' },
{}
{ filterAfterTimestamp: 123456 }
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepStrictEqual(mailMessages, ['DELETE_ALL_EMAILS', 'POLL_EMAIL', 'DELETE_ALL_EMAILS']);
assert.deepStrictEqual(mailMessages, ['POLL_EMAIL', 'DELETE_ALL_EMAILS']);
});
test('verification flow clears 2925 mailbox before polling and after successful signup code submission', async () => {
test('verification flow skips 2925 mailbox preclear when using a fixed signup mail window and still clears after success', async () => {
const mailMessages = [];
const helpers = api.createVerificationFlowHelpers({
@@ -229,13 +227,14 @@ test('verification flow clears 2925 mailbox before polling and after successful
},
{ provider: '2925', label: '2925 邮箱' },
{
filterAfterTimestamp: 123456,
requestFreshCodeFirst: false,
}
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepStrictEqual(mailMessages, ['DELETE_ALL_EMAILS', 'POLL_EMAIL', 'DELETE_ALL_EMAILS']);
assert.deepStrictEqual(mailMessages, ['POLL_EMAIL', 'DELETE_ALL_EMAILS']);
});
test('verification flow completes step 8 and flags phone verification when add-phone appears after login code submit', async () => {