Files
qinglong/test/front/update-progress.test.cjs
T
whyour e8b6a30bd9 feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

Publish release bundles with master images and expose an opt-in develop build
sharing the development index across Alpine and Debian images.
2026-10-18 22:19:49 +08:00

469 lines
15 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.equal(p.nodes('button').length, 0);
assert.equal(p.nodes(p.Modal)[0].props.open, true);
f.store.setJob({ status: 'ready', phase: 'completed' });
p.render();
assert.equal(p.nodes('button').length, 0);
p.nodes(p.Modal)[0].props.onCancel();
p.render({ collapsed: true });
assert.match(p.nodes('button')[0].props.className, /collapsed/);
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 === '已重新启动并运行新版本,无需再次重启。',
),
);
});
test('sidebar and dialog use the same authoritative status even when old phase fields disagree', () => {
const f = fixture(),
notice = f.component('src/components/UpgradeNotice.tsx'),
panel = f.component('src/pages/setting/checkUpdate.tsx');
f.store.setJob({ status: 'ready', phase: 'completed' });
notice.render();
panel.render();
assert.equal(
notice.nodes('button')[0].props['aria-label'],
f.hooks.upgradeStatusLabel(f.store.getSnapshot().job),
);
assert.ok(
panel
.nodes('Text')
.some((node) => node.props.children.includes?.('下载完成,等待重启')),
);
f.store.setJob({ status: 'ready', phase: 'downloading', percent: 62 });
notice.render();
assert.equal(notice.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
f.store.setJob({ status: 'completed', phase: 'ready' });
assert.equal(notice.render(), null);
panel.render();
assert.ok(
panel
.nodes('Text')
.some((node) => node.props.children.includes?.('升级成功')),
);
});