feat: 添加贡献内容更新服务,优化侧边栏贡献模式按钮及相关提示功能
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user