mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:33:06 +08:00
Download updates asynchronously with persistent progress and validate matching source and build artifacts before an explicit restart. Keep upgrade workers alive across service restarts, verify the new runtime, and recover the browser with a one-time success notice. Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and show the sidebar notice only for active, ready or failed jobs. Constrain long account names with ellipsis while preserving avatar and collapse controls.
436 lines
14 KiB
JavaScript
436 lines
14 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const ts = require('typescript');
|
|
|
|
function fixture() {
|
|
const timers = new Map(),
|
|
responses = [],
|
|
requests = [],
|
|
confirms = [],
|
|
errors = [],
|
|
notifications = [];
|
|
const persistent = new Map();
|
|
let timerId = 0,
|
|
reloads = 0,
|
|
snapshot,
|
|
fetchCount = 0;
|
|
const storage = {
|
|
getItem: (key) => persistent.get(key) || null,
|
|
setItem: (key, value) => persistent.set(key, value),
|
|
removeItem: (key) => persistent.delete(key),
|
|
};
|
|
const mocks = {
|
|
'@/utils/config': { apiPrefix: '/panel/api/', authKey: 'token' },
|
|
antd: {
|
|
notification: { success: (options) => notifications.push(options) },
|
|
},
|
|
'react-intl-universal': { get: (key) => key },
|
|
react: { useSyncExternalStore: (subscribe, getSnapshot) => getSnapshot() },
|
|
};
|
|
function load(file, extra = {}) {
|
|
const module = { exports: {} };
|
|
const output = ts.transpileModule(
|
|
fs.readFileSync(path.join(__dirname, '../..', file), 'utf8'),
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2020,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
esModuleInterop: true,
|
|
},
|
|
},
|
|
).outputText;
|
|
new Function(
|
|
'require',
|
|
'module',
|
|
'exports',
|
|
'setTimeout',
|
|
'clearTimeout',
|
|
'fetch',
|
|
'localStorage',
|
|
'sessionStorage',
|
|
'window',
|
|
output,
|
|
)(
|
|
(name) => extra[name] || mocks[name] || require(name),
|
|
module,
|
|
module.exports,
|
|
(fn, ms) => {
|
|
const id = ++timerId;
|
|
timers.set(id, { fn, ms });
|
|
return id;
|
|
},
|
|
(id) => timers.delete(id),
|
|
async (url, options) => {
|
|
fetchCount++;
|
|
requests.push({ url, options });
|
|
const next = responses.shift();
|
|
if (typeof next === 'function') return next(options);
|
|
if (next instanceof Error) throw next;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ code: 200, data: next || { status: 'idle' } }),
|
|
};
|
|
},
|
|
storage,
|
|
storage,
|
|
{ location: { reload: () => reloads++ } },
|
|
);
|
|
return module.exports;
|
|
}
|
|
const hooks = load('src/hooks/useUpgrade.ts');
|
|
const store = hooks.upgradeStore;
|
|
function component(file) {
|
|
let cursor = 0;
|
|
const values = [];
|
|
const Modal = () => null;
|
|
Modal.confirm = (options) => {
|
|
confirms.push(options);
|
|
return { destroy() {} };
|
|
};
|
|
const Component = load(file, {
|
|
'@/hooks/useUpgrade': {
|
|
__esModule: true,
|
|
...hooks,
|
|
default: () => store.getSnapshot(),
|
|
},
|
|
'@/utils/http': {
|
|
request: {
|
|
put: async (url) => {
|
|
requests.push({ url });
|
|
if (url.endsWith('update-check'))
|
|
return {
|
|
code: 200,
|
|
data: {
|
|
hasNewVersion: true,
|
|
lastVersion: '2.24.0',
|
|
lastLog: 'Notes',
|
|
},
|
|
};
|
|
const result = responses.shift();
|
|
if (result instanceof Error) throw result;
|
|
return (
|
|
result || {
|
|
code: 200,
|
|
data: {
|
|
id: 'apply',
|
|
status: 'starting',
|
|
action: 'apply',
|
|
phase: 'verifying',
|
|
},
|
|
}
|
|
);
|
|
},
|
|
},
|
|
},
|
|
'@/pages/setting/checkUpdate': () => null,
|
|
'@ant-design/icons': { CloudDownloadOutlined: 'DownloadIcon' },
|
|
'ansi-to-react': 'Ansi',
|
|
'react-intl-universal': { get: (key) => key },
|
|
antd: {
|
|
Alert: 'Alert',
|
|
Button: 'Button',
|
|
Modal,
|
|
Progress: 'Progress',
|
|
Space: 'Space',
|
|
Tooltip: 'Tooltip',
|
|
Typography: { Text: 'Text' },
|
|
message: { error: (e) => errors.push(e), success() {} },
|
|
},
|
|
react: {
|
|
useState(initial) {
|
|
const index = cursor++;
|
|
if (!(index in values)) values[index] = initial;
|
|
return [values[index], (v) => (values[index] = v)];
|
|
},
|
|
},
|
|
}).default;
|
|
let tree;
|
|
function render(props = {}) {
|
|
cursor = 0;
|
|
tree = Component({ systemInfo: { version: '2.23.0' }, ...props });
|
|
return tree;
|
|
}
|
|
function nodes(type) {
|
|
const found = [];
|
|
const visit = (item) => {
|
|
if (!item || typeof item !== 'object') return;
|
|
if (Array.isArray(item)) return item.forEach(visit);
|
|
if (item.type === type) found.push(item);
|
|
visit(item.props?.children);
|
|
visit(item.props?.description);
|
|
};
|
|
visit(tree);
|
|
return found;
|
|
}
|
|
return { render, nodes, Modal };
|
|
}
|
|
return {
|
|
store,
|
|
hooks,
|
|
responses,
|
|
requests,
|
|
confirms,
|
|
errors,
|
|
notifications,
|
|
component,
|
|
storage,
|
|
timers,
|
|
get fetchCount() {
|
|
return fetchCount;
|
|
},
|
|
get reloads() {
|
|
return reloads;
|
|
},
|
|
async settle() {
|
|
await new Promise(setImmediate);
|
|
snapshot = store.getSnapshot();
|
|
return snapshot;
|
|
},
|
|
async tick(ms = 1000) {
|
|
const entry = [...timers].find(([, v]) => v.ms === ms);
|
|
assert.ok(entry, `timer ${ms} exists`);
|
|
timers.delete(entry[0]);
|
|
entry[1].fn();
|
|
return this.settle();
|
|
},
|
|
};
|
|
}
|
|
|
|
test('sidebar and settings share one poller; mounting restores progress and the last unsubscribe stops it', async () => {
|
|
const f = fixture();
|
|
f.storage.setItem('token', 'secret');
|
|
f.responses.push({ status: 'running', phase: 'downloading', percent: 40 });
|
|
const off1 = f.store.subscribe(() => {}),
|
|
off2 = f.store.subscribe(() => {});
|
|
assert.equal((await f.settle()).job.percent, 40);
|
|
assert.equal(f.fetchCount, 1);
|
|
assert.equal(f.requests[0].url, '/panel/api/system/update-status');
|
|
assert.equal(f.requests[0].options.headers.Authorization, 'Bearer secret');
|
|
off1();
|
|
f.responses.push({ status: 'ready' });
|
|
assert.equal((await f.tick()).job.status, 'ready');
|
|
off2();
|
|
assert.equal(f.timers.size, 0);
|
|
});
|
|
|
|
test('polling reconnects after downtime and recovers ready without forcing a restart', async () => {
|
|
const f = fixture();
|
|
f.responses.push(new Error('offline'));
|
|
const off = f.store.subscribe(() => {});
|
|
assert.equal((await f.settle()).connected, false);
|
|
f.responses.push({ status: 'ready', version: '2.24.0' });
|
|
const recovered = await f.tick();
|
|
assert.equal(recovered.connected, true);
|
|
assert.equal(recovered.job.status, 'ready');
|
|
assert.equal(f.reloads, 0);
|
|
off();
|
|
});
|
|
|
|
test('a timed out status request is aborted and polling continues', async () => {
|
|
const f = fixture();
|
|
f.responses.push(
|
|
({ signal }) =>
|
|
new Promise((resolve, reject) =>
|
|
signal.addEventListener('abort', () => reject(new Error('aborted'))),
|
|
),
|
|
);
|
|
const off = f.store.subscribe(() => {});
|
|
assert.equal((await f.tick(5000)).connected, false);
|
|
assert.equal((await f.tick()).connected, true);
|
|
off();
|
|
});
|
|
|
|
test('stale poll responses cannot overwrite a newly accepted update job', async () => {
|
|
const f = fixture();
|
|
let release;
|
|
f.responses.push(() => new Promise((resolve) => (release = resolve)));
|
|
const off = f.store.subscribe(() => {});
|
|
f.store.beginMutation();
|
|
f.store.setJob({ status: 'starting', id: 'new' });
|
|
f.store.finishMutation();
|
|
release({
|
|
ok: true,
|
|
json: async () => ({ code: 200, data: { status: 'idle' } }),
|
|
});
|
|
assert.equal((await f.settle()).job.id, 'new');
|
|
off();
|
|
});
|
|
|
|
test('completed application reloads the initiating tab once, including after a page refresh', async () => {
|
|
const f = fixture();
|
|
f.storage.setItem(f.hooks.reloadKey, 'update-1');
|
|
f.responses.push({ id: 'update-1', status: 'completed' });
|
|
const off = f.store.subscribe(() => {});
|
|
await f.settle();
|
|
assert.equal(f.reloads, 1);
|
|
f.responses.push({ id: 'update-1', status: 'completed' });
|
|
await f.tick(15000);
|
|
assert.equal(f.reloads, 1);
|
|
off();
|
|
});
|
|
|
|
test('download is nonblocking, progress survives closing the log, and ready exposes explicit apply', async () => {
|
|
const f = fixture(),
|
|
p = f.component('src/pages/setting/checkUpdate.tsx');
|
|
f.store.setJob({ status: 'idle' });
|
|
p.render();
|
|
await p.nodes('Button')[0].props.onClick();
|
|
await f.confirms.at(-1).onOk();
|
|
f.store.setJob({
|
|
status: 'running',
|
|
phase: 'downloading',
|
|
percent: 35,
|
|
downloadedBytes: 35,
|
|
totalBytes: 100,
|
|
});
|
|
p.render();
|
|
assert.equal(p.nodes('Progress')[0].props.percent, 35);
|
|
assert.deepEqual(
|
|
p.nodes('Button').map((node) => node.props.children),
|
|
['查看更新日志'],
|
|
);
|
|
p.nodes('Button').at(-1).props.onClick();
|
|
p.render();
|
|
p.nodes(p.Modal)[0].props.onCancel();
|
|
p.render();
|
|
assert.equal(p.nodes(p.Modal)[0].props.open, false);
|
|
f.store.setJob({ status: 'restarting', phase: 'restarting' });
|
|
p.render();
|
|
assert.deepEqual(
|
|
p.nodes('Button').map((node) => node.props.children),
|
|
['查看更新日志'],
|
|
);
|
|
f.store.setJob({ status: 'ready' });
|
|
p.render();
|
|
p.nodes('Button')[0].props.onClick();
|
|
assert.equal(f.confirms.at(-1).okText, '重启并更新');
|
|
await f.confirms.at(-1).onOk();
|
|
assert.ok(f.requests.some((r) => r.url.endsWith('update/system')));
|
|
assert.equal(f.storage.getItem(f.hooks.reloadKey), 'apply');
|
|
});
|
|
|
|
test('start failure leaves retry enabled and unknown file size has no fake percentage', async () => {
|
|
const f = fixture(),
|
|
p = f.component('src/pages/setting/checkUpdate.tsx');
|
|
f.store.setJob({ status: 'idle' });
|
|
p.render();
|
|
await p.nodes('Button')[0].props.onClick();
|
|
f.responses.push(new Error('offline'));
|
|
await f.confirms.at(-1).onOk();
|
|
assert.equal(f.store.getSnapshot().busy, false);
|
|
assert.equal(f.errors.length, 1);
|
|
f.store.setJob({ status: 'running', phase: 'downloading', percent: null });
|
|
p.render();
|
|
assert.equal(p.nodes('Progress')[0].props.showInfo, false);
|
|
});
|
|
|
|
test('the persistent yellow sidebar entry shows live progress and remains accessible when collapsed', () => {
|
|
const f = fixture(),
|
|
p = f.component('src/components/UpgradeNotice.tsx');
|
|
f.store.setJob({ status: 'running', phase: 'downloading', percent: 62 });
|
|
p.render({ collapsed: false });
|
|
assert.match(p.nodes('button')[0].props['aria-label'], /62%/);
|
|
p.nodes('button')[0].props.onClick();
|
|
p.render({ collapsed: true });
|
|
assert.match(p.nodes('button')[0].props.className, /collapsed/);
|
|
assert.equal(p.nodes(p.Modal)[0].props.open, true);
|
|
f.store.setJob({ status: 'ready', phase: 'ready' });
|
|
p.render();
|
|
assert.equal(p.nodes('button')[0].props['aria-label'], '更新已就绪');
|
|
});
|
|
|
|
test('completed update refreshes once and reminds once after reconnecting the refreshed page', async () => {
|
|
const f = fixture();
|
|
f.storage.setItem(f.hooks.reloadKey, 'job');
|
|
f.responses.push({ id: 'job', status: 'completed', phase: 'completed' });
|
|
const off = f.store.subscribe(() => {});
|
|
await f.settle();
|
|
assert.equal(f.reloads, 1);
|
|
assert.equal(f.notifications.length, 0);
|
|
assert.equal(f.storage.getItem(f.hooks.completionKey), 'job');
|
|
off();
|
|
f.responses.push({ id: 'job', status: 'completed', phase: 'completed' });
|
|
const off2 = f.store.subscribe(() => {});
|
|
await f.settle();
|
|
assert.equal(f.notifications.length, 1);
|
|
assert.equal(f.storage.getItem(f.hooks.completionKey), null);
|
|
f.responses.push({ id: 'job', status: 'completed', phase: 'completed' });
|
|
await f.tick(15000);
|
|
assert.equal(f.notifications.length, 1);
|
|
off2();
|
|
});
|
|
|
|
test('completed sidebar disappears while an open result dialog and completed history remain available', () => {
|
|
const f = fixture(),
|
|
p = f.component('src/components/UpgradeNotice.tsx');
|
|
f.store.setJob({ status: 'running', phase: 'downloading', percent: 12 });
|
|
p.render();
|
|
p.nodes('button')[0].props.onClick();
|
|
f.store.setJob({
|
|
id: 'done',
|
|
status: 'completed',
|
|
phase: 'completed',
|
|
log: 'verified',
|
|
});
|
|
p.render({ collapsed: false });
|
|
assert.equal(p.nodes('button').length, 0);
|
|
assert.equal(p.nodes(p.Modal)[0].props.open, true);
|
|
assert.equal(f.store.getSnapshot().job.status, 'completed');
|
|
assert.equal(f.store.getSnapshot().job.log, 'verified');
|
|
p.nodes(p.Modal)[0].props.onCancel();
|
|
assert.equal(p.render({ collapsed: true }), null);
|
|
});
|
|
|
|
test('sidebar only shows updates that are in progress, ready or failed', () => {
|
|
const f = fixture(),
|
|
p = f.component('src/components/UpgradeNotice.tsx');
|
|
for (const status of ['loading', 'idle', 'completed']) {
|
|
f.store.setJob({ status, phase: status });
|
|
assert.equal(p.render(), null);
|
|
}
|
|
for (const status of [
|
|
'starting',
|
|
'running',
|
|
'restarting',
|
|
'ready',
|
|
'failed',
|
|
]) {
|
|
f.store.setJob({
|
|
status,
|
|
phase: status === 'running' ? 'downloading' : status,
|
|
percent: 12,
|
|
});
|
|
p.render({ collapsed: true });
|
|
assert.equal(p.nodes('button').length, 1);
|
|
}
|
|
});
|
|
|
|
test('update modal uses plain aligned content and explains restart state', () => {
|
|
const f = fixture(),
|
|
p = f.component('src/pages/setting/checkUpdate.tsx');
|
|
f.store.setJob({ status: 'ready', phase: 'ready' });
|
|
p.render();
|
|
assert.equal(p.nodes('Alert').length, 0);
|
|
assert.ok(
|
|
p
|
|
.nodes('Text')
|
|
.some((n) => n.props.children.includes?.('下载完成,等待重启')),
|
|
);
|
|
f.store.setJob({ status: 'completed', phase: 'completed' });
|
|
p.render();
|
|
assert.deepEqual(
|
|
p.nodes('Button').map((n) => n.props.children),
|
|
['检查更新', '查看更新日志'],
|
|
);
|
|
assert.ok(
|
|
p
|
|
.nodes('Text')
|
|
.some(
|
|
(n) => n.props.children === '已重新启动并运行新版本,无需再次重启。',
|
|
),
|
|
);
|
|
});
|