fix: synchronize runtime locales and harden online updates

Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
This commit is contained in:
whyour
2026-10-17 07:07:09 +08:00
parent 8305825ffd
commit ef348b445b
72 changed files with 1733 additions and 553 deletions
+175
View File
@@ -0,0 +1,175 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { Joi, CelebrateError } = require('celebrate');
const storePath = require.resolve('../../back/shared/store');
require.cache[storePath] = {
id: storePath,
filename: storePath,
loaded: true,
exports: { shareStore: { setLang: async () => {} } },
};
const { setLang, t } = require('../../back/shared/i18n');
const validationErrors =
require('../../back/middlewares/validationErrors').default;
const {
scheduleSchema,
commonCronSchema,
} = require('../../back/validation/schedule');
const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
function responseFor(schema, value) {
const validation = schema.validate(value).error;
assert.ok(validation);
const error = new CelebrateError('Validation failed', { celebrated: true });
error.details.set('body', validation);
let response;
const res = {
status(code) {
assert.equal(code, 400);
return this;
},
send(body) {
response = body;
return this;
},
};
validationErrors(error, {}, res, (err) => {
throw err;
});
return response.validation.body.message;
}
test('compiled validation schemas follow live language changes without altering field paths', () => {
const schema = Joi.object({
schedule: scheduleSchema,
log_name: commonCronSchema.log_name,
});
for (const [lang, schedule, empty, logName] of [
[
'en',
'Invalid schedule rule',
'Schedule rule cannot be empty',
'Log name cannot exceed 100 characters',
],
['zh', '无效的定时规则', '定时规则不能为空', '日志名称不能超过100个字符'],
[
'en',
'Invalid schedule rule',
'Schedule rule cannot be empty',
'Log name cannot exceed 100 characters',
],
]) {
setLang(lang);
assert.equal(responseFor(schema, { schedule: 'invalid' }), schedule);
assert.equal(responseFor(schema, { schedule: '' }), empty);
assert.equal(
responseFor(schema, { schedule: '* * * * *', log_name: 'x'.repeat(101) }),
logName,
);
}
setLang('zh');
});
test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => {
const keys = [
'请先初始化',
'请先关闭两步验证',
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片',
'历史版本容量不足,无法安全保存',
'文件已变化,请刷新历史版本后重试',
'trust proxy 配置格式无效',
];
setLang('en');
for (const key of keys) assert.doesNotMatch(t(key), /[\u4e00-\u9fff]/);
assert.equal(t('用户脚本自己的提示'), '用户脚本自己的提示');
setLang('zh');
for (const key of keys) assert.equal(t(key), key);
});
test('upgrade verification uses the supplied language and retains artifact details', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-locale-upgrade-'));
try {
await fs.writeFile(
path.join(root, 'build-info.json'),
JSON.stringify({ version: 1, dirty: true }),
);
await assert.rejects(
upgrade.verifyUpgrade(root, root, { QL_LANG: 'en' }),
/Invalid or dirty build manifest/,
);
await assert.rejects(
upgrade.verifyUpgrade(root, root, { QL_LANG: 'zh' }),
/构建清单无效/,
);
await fs.writeFile(
path.join(root, 'build-info.json'),
JSON.stringify({
version: 1,
dirty: false,
sourceCommit: 'a'.repeat(40),
lockfileSha256: 'b'.repeat(64),
files: {},
}),
);
await assert.rejects(
upgrade.verifyUpgrade(root, root, { QL_LANG: 'en' }),
/Missing build output: build\/app.js/,
);
assert.equal(
upgrade.upgradeMessage('更新失败: %s', { QL_LANG: 'en' }, '文件名.txt'),
'Update failed: 文件名.txt',
);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
test('Shell help switches language and preserves percent characters in script paths', async () => {
const bash = fs.access('/opt/homebrew/bin/bash').then(
() => '/opt/homebrew/bin/bash',
() => 'bash',
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-help-%-'));
try {
const source = await fs.readFile(
path.resolve(__dirname, '../../shell/otask.sh'),
'utf8',
);
const functions = path.join(root, 'functions.sh');
await fs.writeFile(
functions,
source.slice(
0,
source.indexOf('\nhandle_task_start "${task_shell_params[@]}"'),
),
);
for (const lang of ['en', 'zh']) {
const result = spawnSync(
await bash,
[
'-c',
'. shell/share.sh; . "shell/lang/$QL_LANG.sh"; . "$2"; dir_scripts="$1"; cmd_task=task; array_scripts=(); array_scripts_name=(); usage',
'--',
root,
functions,
],
{
cwd: path.resolve(__dirname, '../..'),
env: { ...process.env, QL_LANG: lang },
encoding: 'utf8',
},
);
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(root));
if (lang === 'en') assert.doesNotMatch(result.stdout, /[\u4e00-\u9fff]/);
else assert.match(result.stdout, /暂无脚本可以执行/);
}
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
+88
View File
@@ -0,0 +1,88 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
test('language changes reach live HTTP/scheduler workers and replacement workers restore the saved language', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-language-cluster-'));
const root = path.resolve(__dirname, '../..');
try {
const state = path.join(dir, 'language.json');
await fs.writeFile(state, '"en"');
const fixture = path.join(dir, 'cluster.cjs');
await fs.writeFile(
fixture,
`
require(${JSON.stringify(require.resolve('ts-node/register/transpile-only'))});
const cluster = require('node:cluster');
const fs = require('node:fs/promises');
const storePath = require.resolve(${JSON.stringify(
path.join(root, 'back/shared/store.ts'),
)});
require.cache[storePath] = { id: storePath, filename: storePath, loaded: true, exports: { shareStore: {
getLang: async () => JSON.parse(await fs.readFile(process.env.LANGUAGE_STATE, 'utf8')),
setLang: (lang) => fs.writeFile(process.env.LANGUAGE_STATE, JSON.stringify(lang)),
} } };
const { initializeLanguageSync } = require(${JSON.stringify(
path.join(root, 'back/shared/languageSync.ts'),
)});
const { setLang, t } = require(${JSON.stringify(
path.join(root, 'back/shared/i18n.ts'),
)});
(async () => {
await initializeLanguageSync();
if (cluster.isPrimary) {
const ready = new Map(), observed = new Set(); let phase = 'initial';
cluster.on('message', (worker, message) => {
if (message.kind === 'ready') {
ready.set(worker.id, message.value);
if (phase === 'initial' && ready.size === 2) {
if ([...ready.values()].some(value => value !== 'Update successful')) throw Error('Initial language was not restored');
phase = 'zh'; cluster.workers[1].send({ command: 'switch', lang: 'zh' });
} else if (phase === 'replacement' && ready.size === 3) {
if (message.value !== '更新成功') throw Error('Replacement lost saved language');
phase = 'en'; observed.clear(); cluster.workers[1].send({ command: 'switch', lang: 'en' });
}
}
if (message.kind === 'observed') {
if (message.value !== (phase === 'zh' ? '更新成功' : 'Update successful')) throw Error('Worker language did not change');
observed.add(worker.id);
if (phase === 'zh' && observed.size === 2) { phase = 'replacement'; cluster.fork(); }
else if (phase === 'en' && observed.size === 3) {
console.log(JSON.stringify({ initialWorkers: 2, updatedWorkers: 3, replacementRestored: true }));
for (const w of Object.values(cluster.workers)) w.kill();
}
}
});
cluster.fork(); cluster.fork();
} else {
process.on('message', async message => {
if (message.command === 'switch') await setLang(message.lang);
if (message.type === 'language-changed') process.send({ kind: 'observed', value: t('更新成功') });
});
process.send({ kind: 'ready', value: t('更新成功') });
}
})().catch(error => { console.error(error); process.exit(1); });
`,
);
const result = await promisify(execFile)(process.execPath, [fixture], {
cwd: root,
timeout: 15000,
env: {
...process.env,
TS_NODE_PROJECT: path.join(root, 'back/tsconfig.json'),
LANGUAGE_STATE: state,
},
});
assert.deepEqual(JSON.parse(result.stdout), {
initialWorkers: 2,
updatedWorkers: 3,
replacementRestored: true,
});
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -38,7 +38,7 @@ test('legacy main and extra schedules restore real jobs and healthy readiness',
'./data': { scheduleStacks: stacks },
'../shared/runCron': { runCron() {} },
'../loaders/logger': { info() {}, warn() {} },
'../shared/i18n': { tf: format },
'../shared/i18n': { t: (s) => s, tf: format },
});
const crons = legacySchedules.map((schedule, index) => ({
id: `legacy-${index}`, name: 'legacy', schedule, command: 'true',
+1 -1
View File
@@ -31,7 +31,7 @@ async function fixture() {
'../shared/cronScheduler': { createCronJob: (schedule, callback) => ({ start() {}, cancel() {}, fire: callback }) },
'../shared/cronSchedule': { isValidCronSchedule: x => x !== 'invalid' },
'./data': { scheduleStacks: jobs }, '../shared/runCron': { runCron }, '../loaders/logger': logger,
'../shared/i18n': { tf: s => s },
'../shared/i18n': { t: (s) => s, tf: s => s },
});
const { delCron } = load('back/schedule/delCron.ts', { './data': { scheduleStacks: jobs }, '../loaders/logger': logger });
function add(id = '1', command = 'old', replace = false) {
+3 -3
View File
@@ -48,7 +48,7 @@ test('recovery reconciles a surviving scheduler after missed deletes and disable
},
'../shared/runCron': {},
'../loaders/logger': { info() {}, warn() {} },
'../shared/i18n': { tf: (s) => s },
'../shared/i18n': { t: (s) => s, tf: (s) => s },
});
const client = {
addCron: async (crons, replace) => {
@@ -97,7 +97,7 @@ test('invalid replacement leaves the previous schedule intact', async () => {
'./data': { scheduleStacks: stacks },
'../shared/runCron': {},
'../loaders/logger': {},
'../shared/i18n': { tf: (s) => s },
'../shared/i18n': { t: (s) => s, tf: (s) => s },
});
await assert.rejects(
new Promise((resolve, reject) =>
@@ -122,7 +122,7 @@ test('invalid persisted schedules cannot block recovery, valid jobs or HTTP heal
'./data': { scheduleStacks: stacks },
'../shared/runCron': {},
'../loaders/logger': { info() {}, warn() {} },
'../shared/i18n': { tf: require('node:util').format },
'../shared/i18n': { t: (s) => s, tf: require('node:util').format },
});
const client = { addCron: (crons, replace) => new Promise((resolve, reject) => {
addCron({ request: { crons, replace } }, (error) => error ? reject(error) : resolve());
+3 -2
View File
@@ -225,6 +225,7 @@ for (const branch of ['master', 'develop', 'debian', 'debian-dev'])
const f = await fixture(t, { branch, lockChange: true, tee: true });
let result = await f.execute('update', 'false');
assert.equal(result.code, 0, result.stdout + result.stderr);
assert.ok(result.stdout.includes('     '), 'update logs retain the completion marker');
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
assert.match(
@@ -295,7 +296,7 @@ test('failed health checks and concurrent upgrades are rejected', async (t) => {
await withUpgradeLock(tmp, async () => {
await assert.rejects(
withUpgradeLock(tmp, async () => {}),
/Another upgrade/,
/Another upgrade|已有更新或重载/,
);
});
assert.deepEqual(fs.readdirSync(tmp), []);
@@ -307,6 +308,6 @@ test('failed health checks and concurrent upgrades are rejected', async (t) => {
t.after(() => new Promise((resolve) => server.close(resolve)));
await assert.rejects(
waitForHealth({ QlPort: String(server.address().port) }, 20),
/health check/,
/health check|后端启动检查失败/,
);
});
+109
View File
@@ -0,0 +1,109 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const load = require('../helpers/load-security-module.cjs');
const yaml = require('js-yaml');
function service(request) {
const Service = load('back/services/system.ts', {
undici: { request },
'../config': {
versionFile: 'version.yaml',
lastVersionFile: 'https://version.example/version.yaml',
},
'../config/const': {},
'../config/util': {
parseVersion: async () => ({ version: '2.23.0' }),
parseContentVersion: yaml.load,
},
'../data/dependence': {},
'../data/notify': {},
'../data/system': {},
'../shared/pLimit': {},
'../schedule/client': {},
'../shared/i18n': { t: (key) => key },
'./notify': class {},
'./schedule': class {},
'./sock': class {},
}).default;
return new Service({}, {}, {});
}
for (const [name, request] of [
[
'unreachable server',
async () => {
throw new Error('network down');
},
],
[
'HTTP error with a version-like body',
async () => ({
statusCode: 503,
body: { text: async () => 'version: 2.23.0' },
}),
],
[
'HTML response',
async () => ({
statusCode: 200,
body: { text: async () => '<html>gateway error</html>' },
}),
],
[
'invalid version',
async () => ({
statusCode: 200,
body: { text: async () => 'version: latest' },
}),
],
[
'unreadable body',
async () => ({
statusCode: 200,
body: {
text: async () => {
throw new Error('reset');
},
},
}),
],
])
test(`update check reports failure for ${name}, without claiming the latest version`, async () => {
assert.deepEqual(await service(request).checkUpdate(), {
code: 400,
message: '检查更新失败,请检查网络后重试',
});
});
for (const [version, newer] of [
['2.23.0', false],
['2.23.1', true],
['2.22.0', false],
['3.0.0', true],
])
test(`valid update response ${version}`, async () => {
const result = await service(async (url, options) => {
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.signal.aborted, false);
return {
statusCode: 200,
body: {
text: async () => `version: ${version}\nchangeLog: release notes`,
},
};
}).checkUpdate();
assert.equal(result.code, 200);
assert.equal(result.data.hasNewVersion, newer);
assert.equal(result.data.lastVersion, version);
assert.equal(result.data.lastLog, 'release notes');
});
test('a stalled version request times out instead of hanging the update check', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const checking = service(async (url, { signal }) => {
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
}),
);
}).checkUpdate();
await Promise.resolve();
t.mock.timers.tick(15000);
assert.equal((await checking).code, 400);
});
@@ -0,0 +1,128 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { stageUpgrade } = require('../../shell/upgrade.cjs');
const {
writeManifest,
sourceCommit,
} = require('../helpers/upgrade-fixture.cjs');
async function fixture(
t,
fail,
fallbackMirror = 'gitee',
corrupt = false,
signal,
) {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-download-fallback-'));
t.after(() => fsp.rm(base, { recursive: true, force: true }));
const root = path.join(base, 'root'),
tmp = path.join(base, 'tmp'),
payload = path.join(base, 'payload'),
source = path.join(payload, 'qinglong-' + sourceCommit),
staticRoot = path.join(payload, 'qinglong-static-develop');
for (const dir of [
root,
tmp,
path.join(root, 'node_modules'),
path.join(source, 'sample'),
path.join(staticRoot, 'build'),
])
fs.mkdirSync(dir, { recursive: true });
for (const dir of [root, source]) {
fs.writeFileSync(path.join(dir, 'package.json'), '{}');
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lock');
}
fs.writeFileSync(path.join(source, 'sample/config.sample.sh'), 'sample');
fs.writeFileSync(path.join(staticRoot, 'build/app.js'), 'backend');
writeManifest(source, staticRoot);
if (corrupt)
fs.writeFileSync(path.join(staticRoot, 'build/app.js'), 'tampered');
for (const repo of ['qinglong-static', 'qinglong'])
execFileSync(
'zip',
[
'-qr',
path.join(base, repo + '.zip'),
repo === 'qinglong-static'
? 'qinglong-static-develop'
: 'qinglong-' + sourceCommit,
],
{ cwd: payload },
);
const urls = [];
const operation = stageUpgrade({
root,
tmp,
env: { QL_BRANCH: 'develop' },
mirror: 'github',
fallbackMirror,
signal,
install: async () => assert.fail('unchanged dependencies'),
run: async (program, args) => {
if (program === 'curl') {
const url = args.at(-1);
urls.push(url);
assert.ok(args.includes('--speed-time'));
if (fail(url)) throw new Error('download failed');
const repo = url.includes('/qinglong-static/')
? 'qinglong-static'
: 'qinglong';
fs.copyFileSync(
path.join(base, repo + '.zip'),
args[args.indexOf('--output') + 1],
);
return { stdout: '' };
}
return { stdout: execFileSync(program, args, { encoding: 'utf8' }) };
},
});
return { operation, urls, tmp };
}
test('automatic source selection falls back after GitHub network failure and pins the matching source commit', async (t) => {
const f = await fixture(t, (url) => url.startsWith('https://codeload.'));
await f.operation;
assert.equal(f.urls.length, 4);
assert.match(f.urls[1], /gitee.com.*qinglong-static.*develop/);
assert.ok(f.urls[3].includes(sourceCommit));
assert.ok(fs.existsSync(path.join(f.tmp, 'upgrade-ready-develop.json')));
});
test('source-only network failure keeps the manifest commit when changing mirrors', async (t) => {
const f = await fixture(
t,
(url) => url.startsWith('https://codeload.') && url.includes('/qinglong/'),
);
await f.operation;
assert.equal(f.urls.length, 3);
assert.ok(f.urls[2].endsWith(sourceCommit + '.zip'));
});
test('explicit CLI GitHub selection does not switch providers', async (t) => {
const f = await fixture(t, () => true, null);
await assert.rejects(f.operation, /download failed/);
assert.equal(f.urls.length, 1);
assert.deepEqual(fs.readdirSync(f.tmp), []);
});
test('checksum validation failure never retries from another mirror', async (t) => {
const f = await fixture(t, () => false, 'gitee', true);
await assert.rejects(f.operation, /checksum mismatch|构建文件缺失、损坏或混入旧文件/);
assert.equal(f.urls.length, 2);
assert.deepEqual(fs.readdirSync(f.tmp), []);
});
test('cancellation does not trigger mirror failover', async (t) => {
const controller = new AbortController();
const f = await fixture(
t,
() => {
controller.abort(new Error('cancelled'));
return true;
},
'gitee',
false,
controller.signal,
);
await assert.rejects(f.operation, /cancelled/);
assert.equal(f.urls.length, 1);
});
+5 -5
View File
@@ -88,7 +88,7 @@ for (const legacy of [true, false])
try {
await entered;
const contender = await Promise.race(attempts);
assert.match(contender.error?.message || '', /Another upgrade/);
assert.match(contender.error?.message || '', /Another upgrade|已有更新或重载/);
assert.equal(maximum, 1);
assert.equal((await original.readdir(lock)).length, 1);
} finally {
@@ -106,7 +106,7 @@ test('an empty abandoned lock is acquired atomically and a failed operation rele
withUpgradeLock(tmp, async () => {
await assert.rejects(
withUpgradeLock(tmp, async () => assert.fail('concurrent operation')),
/Another upgrade/,
/Another upgrade|已有更新或重载/,
);
throw new Error('operation failed');
}),
@@ -169,7 +169,7 @@ withUpgradeLock(process.argv[2], async () => {
await withUpgradeLock(tmp, async () => {
await assert.rejects(
withUpgradeLock(tmp, async () => {}),
/Another upgrade/,
/Another upgrade|已有更新或重载/,
);
});
assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']);
@@ -235,7 +235,7 @@ test(
res.once('close', () => clearInterval(timer));
});
const began = Date.now();
await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check/);
await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check|后端启动检查失败/);
assert.ok(
Date.now() - began < 1000,
'deadline must not depend on socket inactivity',
@@ -280,6 +280,6 @@ for (const streaming of [false, true])
test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => {
for (const body of ['{}', '{', ' '.repeat(65537)]) {
const port = await healthServer(t, (req, res) => res.end(body));
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check/);
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check|后端启动检查失败/);
}
});
+34
View File
@@ -0,0 +1,34 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const load = require('../helpers/load-security-module.cjs');
for (const [locale, expected] of [
['en', 'i-default'],
['zh', 'zh-cn'],
])
test(`Monaco uses the matching bundled locale for ${locale}`, () => {
let options;
const { init } = load('src/utils/init.ts', {
'@monaco-editor/react': {
loader: {
config: (value) => {
options = value;
},
},
},
'./config': { baseUrl: '/panel/' },
'react-intl-universal': {
getInitOptions: () => ({ currentLocale: locale }),
},
});
init('2.23.0');
assert.equal(options.paths.vs, '/panel/monaco-editor/min/vs');
assert.equal(options['vs/nls'].availableLanguages['*'], expected);
const fs = require('node:fs');
assert.ok(
fs.existsSync(
'node_modules/monaco-editor/min/vs/editor/editor.main.nls' +
(expected === 'i-default' ? '' : '.' + expected) +
'.js',
),
);
});
+217
View File
@@ -0,0 +1,217 @@
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');
// Drive the real component's handlers and effects without a browser or network.
function panel() {
let cursor = 0,
tree,
subscriber;
const hooks = [],
effects = [],
cleanups = [],
confirms = [],
infos = [],
timers = [],
responses = [];
const react = {
useState(initial) {
const i = cursor++;
if (!(i in hooks)) hooks[i] = initial;
return [
hooks[i],
(v) => {
hooks[i] = typeof v === 'function' ? v(hooks[i]) : v;
},
];
},
useRef(initial) {
const i = cursor++;
return (hooks[i] ||= { current: initial });
},
useCallback(fn) {
cursor++;
return fn;
},
useEffect(fn, deps) {
const i = cursor++;
if (!hooks[i] || deps.some((v, j) => !Object.is(v, hooks[i][j]))) {
hooks[i] = deps;
effects.push(fn);
}
},
};
function modal(options, list) {
const result = {
options,
update(patch) {
Object.assign(options, patch);
},
destroy() {
options.afterClose?.();
},
};
list.push(result);
return result;
}
const mocks = {
'@/utils': { disableBody() {} },
'@/utils/config': { apiPrefix: '/api/' },
'@/utils/http': {
request: {
put: async (url) =>
responses.length
? responses.shift()(url)
: {
code: 200,
data: { hasNewVersion: false, lastVersion: '2.23.0' },
},
},
},
'@/utils/websocket': {
getInstance: () => ({
subscribe(topic, fn) {
subscriber = fn;
},
unsubscribe() {
subscriber = undefined;
},
}),
},
'ansi-to-react': () => null,
antd: {
Button: 'button',
Statistic: { Countdown: 'span' },
message: { loading() {}, destroy() {}, error() {}, success() {} },
Modal: {
confirm: (o) => modal(o, confirms),
info: (o) => modal(o, infos),
},
},
react,
'react-intl-universal': { get: (key) => key },
};
const module = { exports: {} };
const compiled = ts.transpileModule(
fs.readFileSync(
path.join(__dirname, '../../src/pages/setting/checkUpdate.tsx'),
'utf8',
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
jsx: ts.JsxEmit.ReactJSX,
esModuleInterop: true,
},
},
).outputText;
new Function(
'require',
'module',
'exports',
'setTimeout',
'document',
compiled,
)(
(name) => mocks[name] || require(name),
module,
module.exports,
(fn) => {
timers.push(fn);
},
{ querySelector: () => null },
);
function render() {
cursor = 0;
tree = module.exports.default({ systemInfo: { version: '2.23.0' } });
while (effects.length) {
const cleanup = effects.shift()();
if (cleanup) cleanups.push(cleanup);
}
}
async function settle() {
await new Promise(setImmediate);
render();
}
render();
return {
confirms,
infos,
responses,
timers,
settle,
async start() {
tree.props.children[0].props.onClick();
await settle();
confirms.at(-1).options.onOk();
await settle();
return infos.at(-1);
},
async send(payload) {
subscriber?.(payload);
await settle();
},
close() {
infos.at(-1).destroy();
},
unmount() {
cleanups.forEach((fn) => fn());
},
};
}
for (const logged of [false, true])
test(`failed final update event unlocks the modal without an additional log; logged=${logged}`, async () => {
const p = panel(),
m = await p.start();
if (logged) await p.send({ message: 'download failed\n' });
assert.equal(m.options.okButtonProps.disabled, true);
await p.send({ message: '', status: 'failed' });
assert.equal(m.options.okButtonProps.disabled, false);
assert.equal(m.options.closable, true);
});
for (const rejected of [false, true])
test(`update start request failure unlocks modal; rejected=${rejected}`, async () => {
const p = panel();
p.responses.push(
async () => ({ code: 200, data: { hasNewVersion: false } }),
async () => {
if (rejected) throw new Error('offline');
return { code: 400, message: 'busy' };
},
);
const m = await p.start();
assert.equal(m.options.okButtonProps.disabled, false);
assert.equal(m.options.closable, true);
});
test('retry clears the previous failure and ignores notifications without an active modal', async () => {
const p = panel();
await p.send({ status: 'success' });
assert.equal(p.timers.length, 0);
await p.start();
await p.send({ status: 'failed', message: '' });
p.close();
const retry = await p.start();
assert.equal(retry.options.okButtonProps.disabled, true);
assert.equal(retry.options.closable, false);
});
test('successful completion prompts restart once and cancel leaves the log dismissible', async () => {
const p = panel(),
m = await p.start();
await p.send({ status: 'success', message: '' });
await p.send({ status: 'success', message: '' });
p.timers.splice(0).forEach((fn) => fn());
assert.equal(p.confirms.length, 2);
p.confirms.at(-1).options.onCancel();
assert.equal(m.options.okButtonProps.disabled, false);
assert.equal(m.options.closable, true);
});
test('unmount removes the modal and suppresses a pending restart prompt', async () => {
const p = panel();
await p.start();
await p.send({ status: 'success' });
p.unmount();
p.timers.splice(0).forEach((fn) => fn());
assert.equal(p.confirms.length, 1);
});