mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 05:57:39 +02:00
1508 lines
76 KiB
JavaScript
1508 lines
76 KiB
JavaScript
const fs = require('node:fs');
|
|
const vm = require('node:vm');
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const source = fs.readFileSync('web/static/js/update.js', 'utf8');
|
|
const template = fs.readFileSync('web/templates/index.html', 'utf8');
|
|
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
|
|
const settings = fs.readFileSync('web/static/js/settings.js', 'utf8');
|
|
const sheet = fs.readFileSync('web/static/css/style.css', 'utf8');
|
|
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
|
|
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
|
|
const ru = JSON.parse(fs.readFileSync('web/static/i18n/ru-RU.json', 'utf8'));
|
|
|
|
// The only handlers this console is allowed to wire into markup.
|
|
const HANDLES = ['startUpdateApply', 'checkForUpdates', 'rollbackUpdate',
|
|
'updateRestartChoiceChanged', 'loadUpdateConsole',
|
|
'saveUpdateSource', 'previewAdoptSource', 'confirmAdoptSource',
|
|
'startRestartNow', 'refreshUpdatePage'];
|
|
|
|
// The envelope shape GET /api/system/update answers with, plus what the source endpoints return.
|
|
const officialRepo = 'https://github.com/AIPentest/CyberStrikeAI.git';
|
|
const emptySource = { repo: '', configured: false, defaultRepo: officialRepo };
|
|
const configuredSource = { repo: 'https://github.com/Sycun/CyberStrikeAI.git', configured: true, defaultRepo: officialRepo };
|
|
const adoptPlan = {
|
|
root: '/srv/csai',
|
|
source: 'https://github.com/Sycun/CyberStrikeAI.git',
|
|
branch: 'main',
|
|
commit: 'abc1234',
|
|
subject: 'target head',
|
|
incoming: 42,
|
|
overwrittenTotal: 2,
|
|
overwritten: ['web/static/js/a.js', 'web/static/js/b.js'],
|
|
protectedTotal: 1,
|
|
protected: ['roles/我的角色.yaml'],
|
|
};
|
|
|
|
function flatKeys(obj, prefix, out) {
|
|
Object.keys(obj).forEach(k => {
|
|
const v = obj[k];
|
|
const path = prefix ? prefix + '.' + k : k;
|
|
if (v && typeof v === 'object') flatKeys(v, path, out);
|
|
else out.add(path);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function resolve(dict, dotted) {
|
|
let node = dict;
|
|
for (const part of dotted.split('.')) {
|
|
if (!node || typeof node !== 'object') return undefined;
|
|
node = node[part];
|
|
}
|
|
return typeof node === 'string' ? node : undefined;
|
|
}
|
|
|
|
const baseStatus = {
|
|
root: '/srv/csai',
|
|
installed: true,
|
|
branch: 'main',
|
|
repo: officialRepo,
|
|
repoConfigured: false,
|
|
targetBranch: 'main',
|
|
commit: 'a1b2c3d',
|
|
subject: 'fix: 修好一个东西',
|
|
committedAt: '2026-09-30 18:04:11 +0800',
|
|
behind: 3,
|
|
ahead: 0,
|
|
diverged: false,
|
|
updateAvailable: true,
|
|
remoteCommit: 'f9e8d7c',
|
|
remoteSubject: 'feat: 新东西',
|
|
incoming: [{ commit: 'f9e8d7c', subject: 'feat: 新东西' }],
|
|
incomingTotal: 3,
|
|
localChanges: [],
|
|
blockingChanges: [],
|
|
goToolchain: 'go1.23.4',
|
|
canBuild: true,
|
|
hasBinary: true,
|
|
hasRollback: true,
|
|
rollbackTo: 'a1b2c3d',
|
|
checkError: '',
|
|
};
|
|
|
|
function statusOf(patch) {
|
|
return Object.assign({}, baseStatus, patch || {});
|
|
}
|
|
|
|
// The shape is the endpoint's own: {status, job, canRestart, supervised, needsRestart, source}.
|
|
// job is null until an update ran.
|
|
function envelope(status, job, canRestart, source, flags) {
|
|
return {
|
|
status: status || statusOf(),
|
|
job: job === undefined ? null : job,
|
|
canRestart: canRestart !== false,
|
|
supervised: !!(flags && flags.supervised),
|
|
needsRestart: !!(flags && flags.needsRestart),
|
|
binaryBuiltAt: (flags && flags.binaryBuiltAt) || '',
|
|
source: source || emptySource,
|
|
};
|
|
}
|
|
|
|
const runningJob = {
|
|
id: 'upd-1',
|
|
state: 'running',
|
|
started: '2026-10-01T04:00:00+08:00',
|
|
steps: [{ phase: 'fetch', message: '开始拉取更新源(默认分支 main)', at: '2026-10-01T04:00:01+08:00' }],
|
|
restartRequested: false,
|
|
};
|
|
|
|
const succeededJob = {
|
|
id: 'upd-1',
|
|
state: 'succeeded',
|
|
started: '2026-10-01T04:00:00+08:00',
|
|
finished: '2026-10-01T04:03:12+08:00',
|
|
steps: [
|
|
{ phase: 'fetch', message: officialRepo + '(默认分支 main)有 3 个新提交:a1b2c3d → f9e8d7c', at: '04:00:01' },
|
|
{ phase: 'protect', message: '已暂存 2 个本地内容文件', at: '04:00:05' },
|
|
{ phase: 'build', message: '开始编译二进制', at: '04:00:09' },
|
|
{ phase: 'done', message: '已更新 3 个提交并换好二进制', at: '04:03:10' },
|
|
],
|
|
result: {
|
|
fromCommit: 'a1b2c3d',
|
|
toCommit: 'f9e8d7c',
|
|
commits: 3,
|
|
filesTouched: 27,
|
|
keptContent: ['roles/我的角色.md', 'tools/semgrep.yaml'],
|
|
binaryPath: '/srv/csai/cyberstrike-ai',
|
|
binaryBuilt: true,
|
|
prevBinary: '/srv/csai/cyberstrike-ai.prev',
|
|
backupDir: '/srv/csai/.update-backup/20261001_040005',
|
|
needsRestart: true,
|
|
duration: '3m12s',
|
|
},
|
|
restartRequested: true,
|
|
};
|
|
|
|
const failedJob = {
|
|
id: 'upd-2',
|
|
state: 'failed',
|
|
started: '2026-10-01T05:00:00+08:00',
|
|
finished: '2026-10-01T05:00:40+08:00',
|
|
steps: [{ phase: 'build', message: '开始编译二进制(首次会下载依赖,可能需要几分钟)', at: '05:00:20' }],
|
|
failure: {
|
|
reason: 'build_failed',
|
|
message: '编译失败,二进制保持原版本:\ninternal/foo.go:12: undefined: bar',
|
|
items: ['roles/我的角色.md', 'internal/foo.go'],
|
|
},
|
|
restartRequested: false,
|
|
};
|
|
|
|
// harness() loads the page for real (loadUpdateConsole) before handing it back, so every test
|
|
// starts from the markup that would be on screen and can execute the handlers that markup emits.
|
|
function harness(options) {
|
|
const opts = options || {};
|
|
const nodes = new Map();
|
|
const calls = [];
|
|
const toasts = [];
|
|
const confirms = [];
|
|
const timers = [];
|
|
let active = true;
|
|
let confirmAnswer = opts.confirm === false ? false : true;
|
|
let nextTimerId = 1;
|
|
|
|
function makeEl(id) {
|
|
const el = {
|
|
id,
|
|
innerHTML: '',
|
|
checked: false,
|
|
insertAdjacentHTML(_pos, html) { this.innerHTML = html + this.innerHTML; },
|
|
};
|
|
// 控制台住在系统设置页的「一键更新」分区里:页面和分区都得是 active,它才算在前台。
|
|
let seeded = ['page'];
|
|
if (id === 'page-settings') seeded = ['page'].concat(active ? ['active'] : []);
|
|
if (id === 'settings-section-update') seeded = ['settings-section-content'].concat(active ? ['active'] : []);
|
|
el.classList = {
|
|
_set: new Set(seeded),
|
|
contains(c) { return this._set.has(c); },
|
|
add(c) { this._set.add(c); },
|
|
remove(c) { this._set.delete(c); },
|
|
};
|
|
return el;
|
|
}
|
|
|
|
const responses = Object.assign({
|
|
'GET /api/system/update': [{ status: 200, body: envelope(opts.status, opts.job, opts.canRestart, opts.source, opts.flags) }],
|
|
'POST /api/system/update/check': [{ status: 200, body: { status: statusOf(opts.checkedStatus) } }],
|
|
'POST /api/system/update/apply': [{ status: 202, body: { job_id: 'upd-1', state: 'running' } }],
|
|
'GET /api/system/update/job': [{ status: 200, body: { job: opts.job || runningJob } }],
|
|
'POST /api/system/update/rollback': [{ status: 200, body: { result: { fromCommit: 'f9e8d7c', toCommit: 'a1b2c3d' } } }],
|
|
'POST /api/system/update/source': [{ status: 200, body: { source: opts.source || emptySource } }],
|
|
'POST /api/system/update/adopt': [{ status: 200, body: { plan: adoptPlan } }],
|
|
}, opts.responses || {});
|
|
|
|
// The watchdog probes with plain fetch (the app's apiFetch turns a 401 into "logged out",
|
|
// and the whole point here is to read that 401 as "the new process is up"). Tests script
|
|
// the answers: 'ok' (old process still answering), 'unauthorized' (a new process without
|
|
// our session), 'down' (nothing is listening), 'bad gateway'.
|
|
const probeQueue = (opts.probes || ['ok']).slice();
|
|
const fetchProbes = [];
|
|
|
|
const sandbox = {
|
|
document: {
|
|
getElementById(id) {
|
|
if (!nodes.has(id)) nodes.set(id, makeEl(id));
|
|
return nodes.get(id);
|
|
},
|
|
createElement() {
|
|
const el = { textContent: '', innerHTML: '' };
|
|
Object.defineProperty(el, 'textContent', {
|
|
set(v) {
|
|
el._t = String(v == null ? '' : v);
|
|
// The browser's textContent -> innerHTML leaves quotes alone; a harness that
|
|
// escaped them would make the attribute-injection assertions test nothing.
|
|
el.innerHTML = el._t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
},
|
|
get() { return el._t; },
|
|
});
|
|
return el;
|
|
},
|
|
querySelectorAll() { return []; },
|
|
},
|
|
console,
|
|
Promise,
|
|
JSON,
|
|
Set,
|
|
Object,
|
|
String,
|
|
Date,
|
|
Math,
|
|
escapeHtml(text) {
|
|
const div = sandbox.document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
},
|
|
escapeAttr(text) {
|
|
return sandbox.escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
|
|
},
|
|
showNotification(msg, type) { toasts.push({ msg, type }); },
|
|
setInterval(fn, ms) {
|
|
const t = { id: nextTimerId++, fn, ms, cleared: false };
|
|
timers.push(t);
|
|
return t.id;
|
|
},
|
|
clearInterval(id) {
|
|
const t = timers.find(x => x.id === id);
|
|
if (t) t.cleared = true;
|
|
},
|
|
// The page schedules one deferred remote check; a harness without setTimeout would make
|
|
// that code path untestable rather than absent, so the browser global is provided and the
|
|
// timer is recorded instead of fired.
|
|
setTimeout(fn, ms) {
|
|
const t = { id: nextTimerId++, fn, ms, cleared: false, deferred: true };
|
|
timers.push(t);
|
|
return t.id;
|
|
},
|
|
clearTimeout(id) {
|
|
const t = timers.find(x => x.id === id);
|
|
if (t) t.cleared = true;
|
|
},
|
|
window: {
|
|
// Resolved against the same zh-CN.json the browser loads, with {{var}} interpolated the
|
|
// way i18next does. A key the dictionary does not carry comes back as the raw key, so a
|
|
// missing translation fails the copy assertions instead of quietly painting a key name.
|
|
// i18next escapes interpolated values by default (including / -> /); a call that
|
|
// asks for escapeValue:false gets the raw value, exactly like the browser would.
|
|
t(key, o) {
|
|
const found = resolve(zh, key);
|
|
if (found === undefined) return key;
|
|
const escape = !(o && o.interpolation && o.interpolation.escapeValue === false);
|
|
const esc = (v) => escape ? String(v)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''').replace(/\//g, '/')
|
|
: String(v);
|
|
return found.replace(/\{\{(\w+)\}\}/g, (m, name) => (o && name in o ? esc(o[name]) : m));
|
|
},
|
|
showNotification(msg, type) { toasts.push({ msg, type }); },
|
|
confirm(message) { confirms.push(message); return confirmAnswer; },
|
|
applyRBACToUI() {},
|
|
},
|
|
apiFetch(url, reqOpts = {}) {
|
|
const method = reqOpts.method || 'GET';
|
|
calls.push({ url, method, body: reqOpts.body });
|
|
if ((opts.rejects || []).includes(method + ' ' + url)) {
|
|
// Not a status code: the request itself dies (401 handling, a dropped socket),
|
|
// which is the path that leaves a page stuck on a spinner if it is not unwound.
|
|
return Promise.reject(new Error('network unreachable'));
|
|
}
|
|
const queue = responses[method + ' ' + url];
|
|
const entry = queue && queue.length ? (queue.length > 1 ? queue.shift() : queue[0]) : null;
|
|
if (!entry) {
|
|
return Promise.resolve({
|
|
ok: false, status: 500,
|
|
json: () => Promise.resolve({ error: 'no fixture for ' + method + ' ' + url }),
|
|
});
|
|
}
|
|
const status = entry.status === undefined ? 200 : entry.status;
|
|
return Promise.resolve({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: () => Promise.resolve(entry.body),
|
|
});
|
|
},
|
|
fetch(url, reqOpts = {}) {
|
|
fetchProbes.push({ url, opts: reqOpts });
|
|
const answer = probeQueue.length > 1 ? probeQueue.shift() : (probeQueue[0] || 'ok');
|
|
if (answer === 'down') {
|
|
return Promise.reject(new TypeError('Failed to fetch'));
|
|
}
|
|
if (answer === 'unauthorized') {
|
|
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: 'unauthorized' }) });
|
|
}
|
|
if (answer === 'bad gateway') {
|
|
return Promise.resolve({ ok: false, status: 502, json: () => Promise.resolve({}) });
|
|
}
|
|
return Promise.resolve({
|
|
ok: true, status: 200,
|
|
json: () => Promise.resolve(envelope(opts.status, opts.job, opts.canRestart, opts.source, opts.flags)),
|
|
});
|
|
},
|
|
location: {
|
|
pathname: '/',
|
|
hash: '',
|
|
replaced: [],
|
|
reloads: 0,
|
|
replace(target) { this.replaced.push(target); },
|
|
reload() { this.reloads++; },
|
|
},
|
|
};
|
|
sandbox.window.document = sandbox.document;
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(source, sandbox);
|
|
vm.runInContext(`
|
|
this.api = {
|
|
loadUpdateConsole, checkForUpdates, startUpdateApply, rollbackUpdate,
|
|
updateRestartChoiceChanged, pollUpdateJob, renderUpdateConsole, stopUpdatePolling,
|
|
startRestartNow
|
|
};
|
|
this.vars = {
|
|
setBusy(v) { updateBusy = v; },
|
|
getRestartChoice() { return updateRestartChoice; },
|
|
getPollTimer() { return updatePollTimer; },
|
|
getCheckDone() { return updateCheck.done; },
|
|
autoCheckDone() { return updateAutoCheckDone; },
|
|
getWatchdogTimer() { return updateWatchdogTimer; },
|
|
getWatchdogSlow() { return updateWatchdogSlow; },
|
|
restartPending() { return updateRestartPending; },
|
|
// The deadline is wall-clock; tests move it into the past instead of sleeping 90s.
|
|
expireWatchdog() { updateWatchdogDeadline = Date.now() - 1; }
|
|
};
|
|
`, sandbox);
|
|
|
|
const flush = async () => { for (let i = 0; i < 14; i++) await new Promise(r => setImmediate(r)); };
|
|
|
|
// Running the attribute text inside the page's own context is the point of this harness: a
|
|
// handler wired to the wrong function, or reading the wrong element, still looks perfectly
|
|
// fine when the attribute is only ever inspected as a string.
|
|
function runHandler(text, thisArg) {
|
|
const fn = vm.runInContext('(function(){' + text + '})', sandbox);
|
|
return fn.call(thisArg || {});
|
|
}
|
|
|
|
function extractHandlers(html) {
|
|
const found = { onclick: [], onchange: [] };
|
|
const re = /on(click|change)="([^"]*)"/g;
|
|
let m;
|
|
while ((m = re.exec(html)) !== null) found['on' + m[1]].push(m[2]);
|
|
return found;
|
|
}
|
|
|
|
const h = {
|
|
sandbox, nodes, calls, toasts, confirms, timers, flush, runHandler, extractHandlers, responses,
|
|
api: sandbox.api,
|
|
vars: sandbox.vars,
|
|
location: sandbox.location,
|
|
probes: fetchProbes,
|
|
// The deferred auto-check timer is inspected from this side: `timers` is the harness's own
|
|
// array, not something the page's context can see.
|
|
deferredTimers() { return timers.filter(t => t.deferred && !t.cleared).map(t => t.ms); },
|
|
fireDeferred() { timers.filter(t => t.deferred && !t.cleared).forEach(t => { t.cleared = true; t.fn(); }); },
|
|
html() { return nodes.get('update-console').innerHTML; },
|
|
fire(name, thisArg) {
|
|
const all = extractHandlers(h.html());
|
|
const text = all.onclick.concat(all.onchange).find(v => v.startsWith(name + '('));
|
|
if (!text) throw new Error('handler ' + name + ' is not present in the rendered markup');
|
|
return runHandler(text, thisArg);
|
|
},
|
|
// Flipping the router's active classes (the settings page and its update section) is all
|
|
// that leaving means for this console.
|
|
setActive(v) {
|
|
active = v;
|
|
['page-settings', 'settings-section-update'].forEach(id => {
|
|
const node = nodes.get(id);
|
|
if (!node) return;
|
|
if (v) node.classList.add('active'); else node.classList.remove('active');
|
|
});
|
|
},
|
|
setConfirm(v) { confirmAnswer = v; },
|
|
liveTimers() { return timers.filter(t => !t.cleared && !t.deferred); },
|
|
tick() {
|
|
const live = h.liveTimers();
|
|
if (!live.length) throw new Error('no poll timer is armed');
|
|
return live[live.length - 1].fn();
|
|
},
|
|
};
|
|
|
|
return Promise.resolve(sandbox.api.loadUpdateConsole()).then(flush).then(() => h);
|
|
}
|
|
|
|
test('the console lives inside the settings page as its own section', () => {
|
|
// The entry is no longer a page of its own: it is one section of 系统设置, so the main sidebar
|
|
// must not advertise a sibling item for it any more.
|
|
assert.doesNotMatch(template, /data-page="system-update"/,
|
|
'the standalone sidebar entry must be gone: the console is a settings section now');
|
|
assert.doesNotMatch(template, /id="page-system-update"/,
|
|
'the standalone page must be gone: its content moved into 系统设置');
|
|
|
|
const settingsNavItem = template.indexOf('<div class="settings-nav-item" data-section="update" data-require-permission="update:read" onclick="switchSettingsSection(\'update\')">');
|
|
assert.ok(settingsNavItem > -1, 'the settings menu must carry the 一键更新 entry');
|
|
assert.match(template, /<span data-i18n="settings\.nav\.update">一键更新<\/span>/);
|
|
const storageItem = template.indexOf('data-section="storage"');
|
|
assert.ok(storageItem > -1 && storageItem < settingsNavItem, 'the entry must follow 存储清理 in the settings menu');
|
|
|
|
// The console markup sits inside page-settings, wrapped in its own section.
|
|
const pageSettings = template.indexOf('<div id="page-settings" class="page">');
|
|
const section = template.indexOf('<div id="settings-section-update" class="settings-section-content" data-require-permission="update:read">');
|
|
const nextPage = template.indexOf('<!-- 平台权限页面 -->');
|
|
assert.ok(pageSettings > -1 && section > pageSettings && nextPage > section,
|
|
'settings-section-update must live inside page-settings');
|
|
assert.match(template, /id="settings-section-update"[\s\S]{0,700}?id="update-console"/);
|
|
assert.match(template, /id="update-console"/);
|
|
assert.match(template, /<script src="\/static\/js\/update\.js\?v=\{\{\.Version\}\}"><\/script>/);
|
|
assert.match(template, /<div class="settings-section-header update-section-head">/);
|
|
assert.match(template, /<h3 data-i18n="update\.title">/);
|
|
|
|
// Both hash entry points carry the alias, and the retired page id is gone from the whitelists:
|
|
// #system-update has to land on 系统设置 with its update section selected.
|
|
const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : '';/g)];
|
|
assert.equal(aliases.length, 2, 'both hash parsers must normalize #system-update into the settings section');
|
|
const selects = [...router.matchAll(/if \(settingsSection && typeof switchSettingsSection === 'function'\) \{[\s\S]{0,80}?switchSettingsSection\(settingsSection\);/g)];
|
|
assert.equal(selects.length, 2, 'both hash paths must open the update section after switching the page');
|
|
const lists = router.split('\n').filter(l => l.includes("['dashboard'") && l.includes("'settings'"));
|
|
assert.equal(lists.length, 2, 'settings must stay in both router page lists');
|
|
assert.ok(lists.every(l => !l.includes("'system-update'")), 'no whitelist may still name the retired page id');
|
|
assert.doesNotMatch(router, /case 'system-update':/, 'the page init case must be gone with the page');
|
|
// Leaving the settings page stops the poller; switching sections within it is settings.js's job.
|
|
assert.match(router, /if \(pageId !== 'settings' && typeof stopUpdatePolling === 'function'\)/);
|
|
// Coming back to the settings page while the update section is still selected must re-read the
|
|
// install state and re-arm the poller: leaving the page stopped it, and a frozen progress
|
|
// read is exactly the "nothing is happening" picture an operator must never be shown.
|
|
assert.match(router, /case 'settings':[\s\S]{0,450}?getElementById\('settings-section-update'\)[\s\S]{0,250}?loadUpdateConsole\(\)/);
|
|
|
|
// Selecting the section runs the console; selecting anything else stops its poller.
|
|
assert.match(settings, /if \(section === 'update'\) \{[\s\S]{0,160}?loadUpdateConsole\(\)/);
|
|
assert.match(settings, /else if \(typeof stopUpdatePolling === 'function'\) \{[\s\S]{0,120}?stopUpdatePolling\(\);/);
|
|
|
|
// And the console's own idea of "on screen" is the settings page plus that section.
|
|
assert.match(source, /getElementById\('page-settings'\)/);
|
|
assert.match(source, /getElementById\('settings-section-update'\)/);
|
|
});
|
|
|
|
test('the update console has its own stylesheet section', () => {
|
|
const header = sheet.indexOf('一键更新控制台(系统设置页的 settings-section-update 分区)');
|
|
assert.ok(header > -1, 'style.css must carry the update console section header');
|
|
const block = sheet.slice(header);
|
|
assert.ok(block.includes('.update-console'), 'the .update-* section must follow its own section header');
|
|
['.update-console', '.update-section-head', '.update-section-title', '.update-facts', '.update-chip-ok', '.update-chip-warn',
|
|
'.update-chip-danger', '.update-error', '.update-check-failed', '.update-step-list', '.update-table',
|
|
'.update-restart-choice', '.update-blocker-list', '.update-apply-btn:disabled'].forEach(sel => {
|
|
assert.ok(sheet.includes(sel), sel + ' must be defined');
|
|
});
|
|
// Every class the console emits has to be styled or be an existing shared one: a hook name
|
|
// that appears only in JS is a button nobody can see the state of.
|
|
const emitted = [...new Set(source.match(/update-[a-z][a-z-]*/g) || [])]
|
|
.filter(t => /^update-[a-z-]+$/.test(t));
|
|
emitted.forEach(cls => {
|
|
assert.ok(sheet.includes('.' + cls), cls + ' is emitted by the console but style.css never mentions it');
|
|
});
|
|
assert.ok(emitted.length >= 25, 'the class scan found too little to be a gate: ' + emitted.length);
|
|
});
|
|
|
|
test('all locales carry the same update keys, and everything the page asks for exists', () => {
|
|
const zhKeys = flatKeys(zh.update, '', new Set());
|
|
const enKeys = flatKeys(en.update, '', new Set());
|
|
const ruKeys = flatKeys(ru.update, '', new Set());
|
|
assert.deepEqual([...zhKeys].filter(k => !enKeys.has(k)), [], 'keys missing from en-US');
|
|
assert.deepEqual([...enKeys].filter(k => !zhKeys.has(k)), [], 'keys missing from zh-CN');
|
|
assert.deepEqual([...enKeys].filter(k => !ruKeys.has(k)), [], 'keys missing from ru-RU');
|
|
assert.deepEqual([...ruKeys].filter(k => !enKeys.has(k)), [], 'ru-RU has keys other locales do not');
|
|
assert.ok(zhKeys.size >= 60, 'the update namespace has gone thin: ' + zhKeys.size);
|
|
// The settings menu is where this feature's entry lives now; the old nav key must be retired
|
|
// everywhere rather than left behind as a second, drifting label.
|
|
assert.ok(zh.settings.nav.update && en.settings.nav.update && ru.settings.nav.update,
|
|
'settings.nav.update must exist in every locale');
|
|
assert.equal(zh.settings.nav.update, '一键更新');
|
|
assert.equal(en.settings.nav.update, 'One-Click Update');
|
|
assert.equal(ru.settings.nav.update, 'Обновление в один клик');
|
|
[['zh-CN', zh], ['en-US', en], ['ru-RU', ru]].forEach(([label, dict]) => {
|
|
assert.ok(!dict.nav.systemUpdate, label + ' still carries the retired nav.systemUpdate key');
|
|
});
|
|
|
|
// The non-Chinese dictionaries must not be holding Chinese text.
|
|
[...enKeys].forEach(k => {
|
|
const value = resolve(en.update, k) || '';
|
|
assert.ok(!/[一-龥]/.test(value), 'en-US still has Chinese in update.' + k + ': ' + value);
|
|
assert.ok(value !== zh.update[k], 'en-US still has the Chinese string for update.' + k);
|
|
});
|
|
[...ruKeys].forEach(k => {
|
|
const value = resolve(ru.update, k) || '';
|
|
assert.ok(!/[一-龥]/.test(value), 'ru-RU still has Chinese in update.' + k + ': ' + value);
|
|
assert.ok(value !== zh.update[k], 'ru-RU still has the Chinese string for update.' + k);
|
|
});
|
|
|
|
const htmlKeys = [...template.matchAll(/data-i18n="([^"]+)"/g)].map(m => m[1])
|
|
.filter(k => k.startsWith('update.') || k === 'settings.nav.update');
|
|
assert.ok(htmlKeys.length >= 3, 'the page must carry its own translated titles: ' + htmlKeys);
|
|
htmlKeys.forEach(k => {
|
|
assert.ok(resolve(zh, k), 'zh-CN missing ' + k);
|
|
assert.ok(resolve(en, k), 'en-US missing ' + k);
|
|
assert.ok(resolve(ru, k), 'ru-RU missing ' + k);
|
|
});
|
|
|
|
const jsKeys = [...source.matchAll(/updateT\('([^']+)'/g)].map(m => m[1]);
|
|
assert.ok(jsKeys.length >= 40, 'the console should be fully translated, got ' + jsKeys.length);
|
|
jsKeys.forEach(k => {
|
|
assert.ok(resolve(zh, 'update.' + k), 'zh-CN missing update.' + k);
|
|
assert.ok(resolve(en, 'update.' + k), 'en-US missing update.' + k);
|
|
assert.ok(resolve(ru, 'update.' + k), 'ru-RU missing update.' + k);
|
|
});
|
|
|
|
// withUpdateBusy picks its error prefix out of a map, so those keys are referenced dynamically
|
|
// and a typo would survive as a painted key name instead of failing here.
|
|
const mapBody = (source.match(/const UPDATE_FAILURE_KEYS = \{([\s\S]*?)\n\};/) || [])[1] || '';
|
|
const mapped = [...mapBody.matchAll(/'([a-zA-Z]+)'/g)].map(m => m[1]);
|
|
assert.ok(mapped.length >= 4, 'the failure-key map was not found in update.js');
|
|
mapped.forEach(k => {
|
|
assert.ok(resolve(zh, 'update.' + k), 'zh-CN missing update.' + k);
|
|
assert.ok(resolve(en, 'update.' + k), 'en-US missing update.' + k);
|
|
assert.ok(resolve(ru, 'update.' + k), 'ru-RU missing update.' + k);
|
|
});
|
|
|
|
// No key may be dead weight: every entry has to be reachable from the page or the console.
|
|
[...zhKeys].forEach(k => {
|
|
const used = source.includes("'" + k.split('.').pop() + "'") || template.includes('update.' + k);
|
|
assert.ok(used, 'update.' + k + ' is defined but nothing asks for it');
|
|
});
|
|
});
|
|
|
|
test('opening the page reads the install tree and claims nothing about the remote', async () => {
|
|
const h = await harness({ status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }) });
|
|
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update'],
|
|
'opening the page must not fetch the remote and must not start polling');
|
|
|
|
const html = h.html();
|
|
assert.match(html, /\/srv\/csai/);
|
|
assert.match(html, /main/);
|
|
assert.match(html, /a1b2c3d/);
|
|
assert.match(html, /2026-09-30 18:04:11/);
|
|
assert.match(html, /git 工作树/);
|
|
assert.match(html, /go1\.23\.4/);
|
|
assert.match(html, /有可回滚点/);
|
|
assert.match(html, /待并入|本机安装/, 'the console must be laid out in labelled sections');
|
|
// behind is 0 only because nobody looked yet: that is "not checked", never "up to date".
|
|
assert.match(html, /还没查过更新源/);
|
|
assert.doesNotMatch(html, /已是最新/, 'a page that did not check must not say 已是最新');
|
|
assert.doesNotMatch(html, /update\.[a-zA-Z]/, 'a raw i18n key leaked into the markup');
|
|
assert.doesNotMatch(html, /\{\{/, 'an un-interpolated placeholder leaked into the markup');
|
|
});
|
|
|
|
test('a check that could not run is reported as a failure, never as "already up to date"', async () => {
|
|
const h = await harness({
|
|
status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }),
|
|
responses: {
|
|
'POST /api/system/update/check': [{
|
|
status: 200,
|
|
body: {
|
|
status: statusOf({
|
|
checkError: 'fatal: unable to access github: Could not resolve host',
|
|
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
|
|
}),
|
|
},
|
|
}],
|
|
},
|
|
});
|
|
h.calls.length = 0;
|
|
h.toasts.length = 0;
|
|
await h.api.checkForUpdates();
|
|
await h.flush();
|
|
|
|
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['POST /api/system/update/check'],
|
|
'a check must not re-read the tree and must not start a poller');
|
|
const html = h.html();
|
|
assert.match(html, /检查失败:/, 'the reason must be stated as a check failure');
|
|
assert.match(html, /Could not resolve host/);
|
|
assert.doesNotMatch(html, /已是最新/, 'a failed check must never be painted as up to date');
|
|
assert.equal(h.toasts.length, 1);
|
|
assert.match(h.toasts[0].msg, /检查失败:/);
|
|
assert.equal(h.toasts[0].type, 'error', 'a check that failed must not toast success');
|
|
assert.equal(h.liveTimers().length, 0, 'a check is not a job: nothing may be polling');
|
|
});
|
|
|
|
test('a transport failure of the check endpoint is still "查不了"', async () => {
|
|
const h = await harness({
|
|
responses: { 'POST /api/system/update/check': [{ status: 500, body: { error: 'git exited 128' } }] },
|
|
});
|
|
h.toasts.length = 0;
|
|
await h.api.checkForUpdates();
|
|
await h.flush();
|
|
const html = h.html();
|
|
assert.match(html, /检查失败:/);
|
|
assert.match(html, /git exited 128/);
|
|
assert.doesNotMatch(html, /已是最新/);
|
|
assert.match(h.toasts[h.toasts.length - 1].msg, /检查失败:git exited 128/);
|
|
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
|
|
});
|
|
|
|
test('a check whose request dies mid-flight releases the button and says so', async () => {
|
|
const h = await harness({ rejects: ['POST /api/system/update/check'] });
|
|
h.toasts.length = 0;
|
|
await h.api.checkForUpdates();
|
|
await h.flush();
|
|
const html = h.html();
|
|
assert.match(html, /检查失败:/, 'a dead request is a failure, and it has to be called one');
|
|
assert.match(html, /network unreachable/);
|
|
assert.doesNotMatch(html, /正在检查更新源/, 'the button must not stay frozen on "checking"');
|
|
assert.doesNotMatch(html, /update-check-btn" disabled/, 'and it must be clickable again');
|
|
assert.doesNotMatch(html, /已是最新/, 'a request that went nowhere is not an answer about the remote');
|
|
assert.match(html, /还没查过更新源/, 'the page falls back to the honest "nobody looked yet"');
|
|
assert.equal(h.toasts.length, 1);
|
|
assert.equal(h.toasts[0].type, 'error');
|
|
assert.equal(h.liveTimers().length, 0);
|
|
});
|
|
|
|
test('behind is counted from incomingTotal and a truncated list says so', async () => {
|
|
const many = [];
|
|
for (let i = 0; i < 20; i++) many.push({ commit: 'c' + i, subject: '提交 ' + i });
|
|
const h = await harness({
|
|
status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }),
|
|
responses: {
|
|
'POST /api/system/update/check': [{
|
|
status: 200,
|
|
body: { status: statusOf({ updateAvailable: true, behind: 42, incomingTotal: 42, incoming: many }) },
|
|
}],
|
|
},
|
|
});
|
|
await h.api.checkForUpdates();
|
|
await h.flush();
|
|
const html = h.html();
|
|
assert.match(html, /落后 42 个提交/, 'the headline number is the total, not the length of the page');
|
|
assert.match(html, /共 42 个新提交/);
|
|
assert.match(html, /只列出前 20 个/);
|
|
assert.match(html, /提交 19/);
|
|
});
|
|
|
|
test('local edits, a diverged tree and a clean-but-unchecked tree each block the button', async () => {
|
|
const cases = [
|
|
{
|
|
patch: { blockingChanges: [{ path: 'internal/foo.go', status: 'modified', protected: false }] },
|
|
copy: /有 1 个产品源码文件被本地改过/,
|
|
path: /internal\/foo\.go/,
|
|
},
|
|
{
|
|
patch: { diverged: true, ahead: 4, behind: 2, blockingChanges: [] },
|
|
copy: /本地领先 4 个提交/,
|
|
path: null,
|
|
},
|
|
{
|
|
patch: { updateAvailable: false, behind: 0, blockingChanges: [] },
|
|
copy: /更新源没有比本机更新的提交/,
|
|
path: null,
|
|
},
|
|
];
|
|
for (const c of cases) {
|
|
const h = await harness({ status: statusOf(c.patch) });
|
|
const html = h.html();
|
|
assert.match(html, /<button class="btn-primary update-apply-btn" disabled[^>]*onclick="startUpdateApply\(\)">/,
|
|
'the apply button must be disabled for: ' + c.copy);
|
|
assert.match(html, c.copy, 'a disabled button must say why');
|
|
assert.match(html, /data-require-permission="update:apply"/);
|
|
if (c.path) assert.match(html, c.path, 'the blocking file names must be listed, not just counted');
|
|
// Disabled in the markup and refused in code: the two must agree.
|
|
h.calls.length = 0;
|
|
h.toasts.length = 0;
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
assert.deepEqual(h.calls, [], 'a blocked apply must never reach the server: ' + c.copy);
|
|
assert.equal(h.toasts.length, 1, 'the refusal must be said out loud instead of failing silent');
|
|
assert.equal(h.toasts[0].type, 'error');
|
|
}
|
|
|
|
const busy = await harness({ status: statusOf(), job: runningJob });
|
|
assert.match(busy.html(), /<button class="btn-primary update-apply-btn" disabled/);
|
|
assert.match(busy.html(), /已有更新在进行中/);
|
|
assert.match(busy.html(), /<button class="btn-secondary update-rollback-btn" disabled/);
|
|
assert.deepEqual(busy.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update']);
|
|
assert.equal(busy.liveTimers().length, 1, 'opening onto a running job resumes the progress read');
|
|
});
|
|
|
|
test('a tree that only owes a build still offers the button, and says it is a build', async () => {
|
|
// Source already at the update source's tip, binary stale: the retry the no_toolchain
|
|
// message talks about has to be reachable from this page. "0 new commits" is not a
|
|
// reason to disable the button, and the confirm dialog must not claim nothing will
|
|
// happen.
|
|
const builtJob = {
|
|
id: 'upd-9',
|
|
state: 'succeeded',
|
|
started: '2026-10-08T10:00:00+08:00',
|
|
finished: '2026-10-08T10:00:40+08:00',
|
|
steps: [{ phase: 'build', message: '开始编译二进制(首次会下载依赖,可能需要几分钟)', at: '10:00:01' }],
|
|
result: {
|
|
fromCommit: 'a1b2c3d', toCommit: 'a1b2c3d',
|
|
commits: 0, filesTouched: 0, keptContent: [],
|
|
binaryPath: '/srv/csai/cyberstrike-ai',
|
|
binaryBuilt: true, prevBinary: '/srv/csai/cyberstrike-ai.prev',
|
|
backupDir: '/srv/csai/.update-backup/20261008_100000',
|
|
needsRestart: true, duration: '40s',
|
|
},
|
|
restartRequested: false,
|
|
};
|
|
const pendingStatus = statusOf({
|
|
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0, buildPending: true,
|
|
});
|
|
const h = await harness({
|
|
status: pendingStatus,
|
|
responses: {
|
|
'GET /api/system/update/job': [{ status: 200, body: { job: builtJob } }],
|
|
'GET /api/system/update': [
|
|
{ status: 200, body: envelope(pendingStatus, null, true) },
|
|
{ status: 200, body: envelope(statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0, commit: 'a1b2c3d' }), builtJob, true) },
|
|
],
|
|
},
|
|
});
|
|
const html = h.html();
|
|
assert.match(html, /<button class="btn-primary update-apply-btn" data-require-permission="update:apply" onclick="startUpdateApply\(\)">/,
|
|
'a pending build must not read as "nothing to update" and disable the button');
|
|
assert.match(html, /补上这次编译/, 'the page must say what pressing update will do');
|
|
assert.doesNotMatch(html, /更新源没有比本机更新的提交/);
|
|
|
|
h.calls.length = 0;
|
|
h.toasts.length = 0;
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
const apply = h.calls.find(c => c.method === 'POST' && c.url === '/api/system/update/apply');
|
|
assert.ok(apply, 'the retry must reach the server: ' + h.calls.map(c => c.method + ' ' + c.url));
|
|
assert.match(h.confirms[h.confirms.length - 1], /补上二进制/,
|
|
'the confirm dialog describes a build, not "0 new commits"');
|
|
const success = h.toasts.filter(t => t.type === 'success' && /补编译完成/.test(t.msg)).pop();
|
|
assert.ok(success, 'a finished build retry is announced as a build: ' + JSON.stringify(h.toasts));
|
|
assert.deepEqual(h.toasts.filter(t => t.type === 'success' && /a1b2c3d → a1b2c3d/.test(t.msg)), [],
|
|
'a build-only retry must not be announced as "update a1b2c3d → a1b2c3d"');
|
|
});
|
|
|
|
test('a pending build with no toolchain fails with the server sentence, never a success', async () => {
|
|
const failed = {
|
|
id: 'upd-3',
|
|
state: 'failed',
|
|
started: '2026-10-08T11:00:00+08:00',
|
|
finished: '2026-10-08T11:00:01+08:00',
|
|
steps: [],
|
|
failure: {
|
|
reason: 'no_toolchain',
|
|
message: '源码已经是更新源最新(a1b2c3d),但二进制还是旧的:本机没有 Go 工具链,无法补编译。装好 go 后再点一次更新即可补上二进制。',
|
|
},
|
|
restartRequested: false,
|
|
};
|
|
const h = await harness({
|
|
status: statusOf({
|
|
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
|
|
buildPending: true, canBuild: false, goToolchain: '',
|
|
}),
|
|
responses: {
|
|
'GET /api/system/update/job': [{ status: 200, body: { job: failed } }],
|
|
},
|
|
});
|
|
// 按钮仍可点:缺工具链是环境事实,让操作员看到服务端那句"装好 go 再来"才是出路。
|
|
assert.match(h.html(), /<button class="btn-primary update-apply-btn" data-require-permission="update:apply" onclick="startUpdateApply\(\)">/);
|
|
await h.api.startUpdateApply();
|
|
await h.flush();
|
|
assert.deepEqual(h.toasts.filter(t => t.type === 'success' && /更新完成|补编译完成/.test(t.msg)), [],
|
|
'a build that did not happen must never be announced as done: ' + JSON.stringify(h.toasts));
|
|
assert.ok(h.toasts.some(t => t.type === 'error' && /Go 工具链/.test(t.msg)),
|
|
'the server sentence must be shown: ' + JSON.stringify(h.toasts));
|
|
});
|
|
|
|
test('applying posts {"restart":false} and then reads the job every 1.5 seconds', async () => {
|
|
// This test is about the poll cadence and the final re-read, so the job finishes without
|
|
// having asked for a restart; the restart hand-off has its own test below.
|
|
const plainSucceededJob = Object.assign({}, succeededJob, { restartRequested: false });
|
|
const h = await harness({
|
|
responses: {
|
|
'POST /api/system/update/apply': [{ status: 202, body: { job_id: 'upd-1', state: 'running' } }],
|
|
'GET /api/system/update/job': [
|
|
{ status: 200, body: { job: runningJob } },
|
|
{ status: 200, body: { job: runningJob } },
|
|
{ status: 200, body: { job: plainSucceededJob } },
|
|
],
|
|
'GET /api/system/update': [
|
|
{ status: 200, body: envelope(statusOf(), null, true) },
|
|
{
|
|
status: 200,
|
|
body: envelope(statusOf({
|
|
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
|
|
commit: 'f9e8d7c',
|
|
}), plainSucceededJob, true),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.match(h.html(), /<button class="btn-primary update-apply-btn" data-require-permission="update:apply" onclick="startUpdateApply\(\)">/,
|
|
'a clean tree that is behind must offer the button, undimmed');
|
|
|
|
h.calls.length = 0;
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
|
|
const apply = h.calls.find(c => c.method === 'POST' && c.url === '/api/system/update/apply');
|
|
assert.ok(apply, 'no apply was posted: ' + h.calls.map(c => c.method + ' ' + c.url));
|
|
assert.deepEqual(JSON.parse(apply.body), { restart: false },
|
|
'the body is one object carrying a restart boolean, not a positional bag');
|
|
assert.equal(h.liveTimers().length, 1, 'exactly one poller: ' + JSON.stringify(h.timers.map(t => t.ms)));
|
|
assert.equal(h.liveTimers()[0].ms, 1500, 'progress must be read every 1.5 seconds');
|
|
|
|
// In the browser the poller runs off the timer; here the test drives the armed callback so the
|
|
// whole progress path is exercised without waiting on wall-clock time.
|
|
h.calls.length = 0;
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update/job']);
|
|
assert.match(h.html(), /开始拉取更新源/, 'each step line must be rendered as it arrives');
|
|
assert.match(h.html(), /进行中/);
|
|
assert.equal(h.liveTimers().length, 1);
|
|
|
|
h.calls.length = 0;
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.equal(h.liveTimers().length, 0, 'the timer must be cleared once the job reaches a final state');
|
|
assert.equal(h.vars.getPollTimer(), null);
|
|
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url),
|
|
['GET /api/system/update/job', 'GET /api/system/update'],
|
|
'once the job ends the install tree is re-read: the binary on disk has changed');
|
|
|
|
const html = h.html();
|
|
assert.match(html, /a1b2c3d/);
|
|
assert.match(html, /f9e8d7c/);
|
|
assert.match(html, /并入提交数[\s\S]{0,160}?>3</);
|
|
assert.match(html, /改动文件数[\s\S]{0,160}?>27</);
|
|
assert.match(html, /roles\/我的角色\.md/, 'keptContent must be listed by name');
|
|
assert.match(html, /tools\/semgrep\.yaml/);
|
|
assert.match(html, /二进制已换新/);
|
|
assert.match(html, /需要重启才会运行新版本/);
|
|
assert.match(html, /3m12s/);
|
|
const success = h.toasts.filter(t => t.type === 'success' && /更新完成/.test(t.msg)).pop();
|
|
assert.ok(success, 'a finished update must be announced: ' + JSON.stringify(h.toasts));
|
|
assert.match(success.msg, /f9e8d7c/, 'the toast names the commit the tree is on now');
|
|
});
|
|
|
|
test('a failing job shows the server message with its item list, and never claims success', async () => {
|
|
const h = await harness({
|
|
responses: {
|
|
'GET /api/system/update/job': [{ status: 200, body: { job: failedJob } }],
|
|
// GET /api/system/update also carries h.latest(), so the re-read after the job ends
|
|
// still has the finished job in it: the console must not lose the failure.
|
|
'GET /api/system/update': [
|
|
{ status: 200, body: envelope(statusOf(), null, true) },
|
|
{ status: 200, body: envelope(statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }), failedJob, true) },
|
|
],
|
|
},
|
|
});
|
|
await h.api.startUpdateApply();
|
|
await h.flush();
|
|
|
|
const html = h.html();
|
|
assert.match(html, /编译失败,二进制保持原版本/);
|
|
assert.match(html, /build_failed/);
|
|
assert.match(html, /internal\/foo\.go/, 'the failure item list must be shown');
|
|
assert.equal(h.liveTimers().length, 0, 'a finished job must not keep polling');
|
|
assert.deepEqual(h.toasts.filter(t => t.type === 'success' && /更新完成/.test(t.msg)), [],
|
|
'a failed update must not be announced as finished: ' + JSON.stringify(h.toasts));
|
|
assert.match(h.toasts[h.toasts.length - 1].msg, /编译失败/);
|
|
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
|
|
});
|
|
|
|
test('the restart tick only appears when the process can actually be restarted', async () => {
|
|
const off = await harness({ canRestart: false });
|
|
assert.doesNotMatch(off.html(), /id="update-restart-choice"/,
|
|
'offering a promise the platform cannot keep is a lie');
|
|
assert.match(off.html(), /本次启动没有提供重启钩子/);
|
|
|
|
const on = await harness({
|
|
canRestart: true,
|
|
// No job to adopt: a running job would (correctly) block the second click, and this test
|
|
// is about what the checkbox does to the body, not about the block.
|
|
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] },
|
|
});
|
|
assert.match(on.html(), /<input type="checkbox" id="update-restart-choice"[^>]*onchange="updateRestartChoiceChanged\(this\.checked\)">/);
|
|
assert.ok(!/id="update-restart-choice" checked/.test(on.html()),
|
|
'with nothing supervising the process, the tick must default to off: ticking it would just stop the platform');
|
|
|
|
// The default follows the environment: launchd/systemd markers mean a restart is a
|
|
// restart, so one click should be the whole update.
|
|
const supervised = await harness({
|
|
canRestart: true,
|
|
flags: { supervised: true },
|
|
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] },
|
|
});
|
|
assert.match(supervised.html(), /id="update-restart-choice" checked/,
|
|
'detected supervision must pre-tick the box');
|
|
// Once the operator decides for themselves, the default stops second-guessing them.
|
|
supervised.sandbox.document.getElementById('update-restart-choice').checked = false;
|
|
supervised.fire('updateRestartChoiceChanged', { checked: false });
|
|
assert.ok(!/id="update-restart-choice" checked/.test(supervised.html()));
|
|
|
|
on.calls.length = 0;
|
|
on.fire('startUpdateApply');
|
|
await on.flush();
|
|
assert.deepEqual(JSON.parse(on.calls.find(c => c.url === '/api/system/update/apply').body), { restart: false });
|
|
|
|
// Tick it through the rendered onchange, exactly as a browser does (the element is already
|
|
// checked when change fires), then apply again.
|
|
on.vars.setBusy(false);
|
|
on.nodes.get('update-restart-choice').checked = true;
|
|
on.fire('updateRestartChoiceChanged', { checked: true });
|
|
assert.equal(on.vars.getRestartChoice(), true);
|
|
assert.match(on.html(), /id="update-restart-choice" checked/, 'the tick must survive the repaint');
|
|
|
|
on.calls.length = 0;
|
|
on.vars.setBusy(false);
|
|
on.fire('startUpdateApply');
|
|
await on.flush();
|
|
assert.deepEqual(JSON.parse(on.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true },
|
|
'the restart flag that goes out is the checkbox, not a guess');
|
|
});
|
|
|
|
test('the confirm dialog says what the update will do, and cancelling posts nothing', async () => {
|
|
const h = await harness();
|
|
h.setConfirm(false);
|
|
h.calls.length = 0;
|
|
h.toasts.length = 0;
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
assert.equal(h.confirms.length, 1, 'the operator must be asked before the tree moves');
|
|
assert.match(h.confirms[0], /重新编译/, 'the confirm must say the binary gets rebuilt: ' + h.confirms[0]);
|
|
assert.match(h.confirms[0], /二进制/);
|
|
assert.match(h.confirms[0], /github\.com\/AIPentest\/CyberStrikeAI/, 'the confirm must name the repository being fetched');
|
|
assert.match(h.confirms[0], /a1b2c3d/, 'the confirm must name where this machine is now');
|
|
assert.doesNotMatch(h.confirms[0], /\{\{/, 'the confirm went out un-interpolated: ' + h.confirms[0]);
|
|
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), [], 'a cancelled update must not POST');
|
|
assert.deepEqual(h.toasts, [], 'a cancelled update must not be reported as anything');
|
|
});
|
|
|
|
test('a second apply is refused with 409 and the running job is adopted', async () => {
|
|
const h = await harness({
|
|
responses: {
|
|
'POST /api/system/update/apply': [{
|
|
status: 409,
|
|
body: {
|
|
error: '已有一次更新在进行中',
|
|
job: { id: 'upd-7', state: 'running', started: 'now', steps: [{ phase: 'build', message: '正在编译二进制', at: 'now' }] },
|
|
},
|
|
}],
|
|
},
|
|
});
|
|
await h.api.startUpdateApply();
|
|
await h.flush();
|
|
const apply = h.calls.find(c => c.url === '/api/system/update/apply');
|
|
assert.equal(apply.method, 'POST');
|
|
assert.deepEqual(JSON.parse(apply.body), { restart: false });
|
|
assert.match(h.html(), /正在编译二进制/, 'the job the server named must be shown, not swallowed');
|
|
assert.match(h.html(), /upd-7/);
|
|
assert.match(h.toasts[h.toasts.length - 1].msg, /已有一次更新在进行中/);
|
|
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
|
|
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), [], 'a refusal must not toast success');
|
|
assert.equal(h.liveTimers().length, 1, 'the adopted job must be watched');
|
|
});
|
|
|
|
test('rollback only shows with a rollback point, asks first, and re-reads the tree', async () => {
|
|
const none = await harness({ status: statusOf({ hasRollback: false, rollbackTo: '' }) });
|
|
assert.doesNotMatch(none.html(), /update-rollback-btn/);
|
|
assert.match(none.html(), /没有可回滚点/);
|
|
|
|
const h = await harness({ status: statusOf() });
|
|
assert.match(h.html(), /<button class="btn-secondary update-rollback-btn" data-require-permission="update:apply" onclick="rollbackUpdate\(\)">/);
|
|
assert.match(h.html(), /回滚目标:a1b2c3d/);
|
|
|
|
h.calls.length = 0;
|
|
h.toasts.length = 0;
|
|
h.setConfirm(false);
|
|
h.fire('rollbackUpdate');
|
|
await h.flush();
|
|
assert.deepEqual(h.calls, [], 'a cancelled rollback must not POST');
|
|
assert.equal(h.confirms.length, 1);
|
|
assert.match(h.confirms[0], /a1b2c3d/);
|
|
|
|
h.setConfirm(true);
|
|
h.calls.length = 0;
|
|
h.vars.setBusy(false);
|
|
h.fire('rollbackUpdate');
|
|
await h.flush();
|
|
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url),
|
|
['POST /api/system/update/rollback', 'GET /api/system/update'],
|
|
'a rollback must be followed by a fresh read of the install tree');
|
|
assert.equal(h.calls[0].body, undefined, 'rollback takes no body');
|
|
assert.match(h.toasts[h.toasts.length - 1].msg, /已回滚到 a1b2c3d/);
|
|
assert.equal(h.toasts[h.toasts.length - 1].type, 'success');
|
|
assert.doesNotMatch(h.html(), /已是最新/, 'after a rollback the page has not looked at the remote');
|
|
});
|
|
|
|
test('a refused rollback repeats the server reason and its file list', async () => {
|
|
const h = await harness({
|
|
responses: {
|
|
'POST /api/system/update/rollback': [{
|
|
status: 409,
|
|
body: { error: 'HEAD 已经移动,不是那次更新留下的位置', reason: 'moved_since_update', items: ['roles/我的角色.md'] },
|
|
}],
|
|
},
|
|
});
|
|
await h.api.rollbackUpdate();
|
|
await h.flush();
|
|
assert.match(h.html(), /回滚失败/);
|
|
assert.match(h.html(), /HEAD 已经移动/);
|
|
assert.match(h.html(), /roles\/我的角色\.md/);
|
|
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
|
|
assert.match(h.toasts[h.toasts.length - 1].msg, /HEAD 已经移动/);
|
|
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), []);
|
|
});
|
|
|
|
test('a 202 that carries an error is not mistaken for success', async () => {
|
|
// The handler answers "the source went back but the binary could not be rebuilt" with HTTP
|
|
// 202 plus an error body (statusForError maps no_toolchain onto 202). Trusting resp.ok alone
|
|
// toasts 已回滚 over a rollback that only half happened.
|
|
const h = await harness({
|
|
responses: {
|
|
'POST /api/system/update/rollback': [{
|
|
status: 202,
|
|
body: { error: '源码已回到更新前,但本机没有 Go 工具链,二进制没换', reason: 'no_toolchain' },
|
|
}],
|
|
},
|
|
});
|
|
h.toasts.length = 0;
|
|
h.calls.length = 0;
|
|
await h.api.rollbackUpdate();
|
|
await h.flush();
|
|
assert.match(h.html(), /回滚失败/);
|
|
assert.match(h.html(), /二进制没换/);
|
|
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), [],
|
|
'a 2xx carrying an error must never be toasted as success: ' + JSON.stringify(h.toasts));
|
|
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
|
|
assert.match(h.toasts[h.toasts.length - 1].msg, /二进制没换/);
|
|
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['POST /api/system/update/rollback'],
|
|
'a half-finished rollback must not be followed by a victory lap through the status endpoint');
|
|
});
|
|
|
|
test('leaving the page kills the poller, and reopening arms exactly one', async () => {
|
|
const h = await harness();
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
assert.equal(h.liveTimers().length, 1);
|
|
|
|
h.calls.length = 0;
|
|
h.setActive(false);
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.equal(h.liveTimers().length, 0, 'switching pages must clear the interval');
|
|
assert.deepEqual(h.calls, [], 'a page that is not on screen must not keep asking');
|
|
assert.equal(h.vars.getPollTimer(), null);
|
|
|
|
h.setActive(true);
|
|
await h.api.loadUpdateConsole();
|
|
assert.equal(h.liveTimers().length, 0, 'a clean open arms nothing');
|
|
|
|
const busy = await harness({ job: { id: 'upd-3', state: 'running', started: 'x', steps: [] } });
|
|
assert.equal(busy.liveTimers().length, 1, 'opening onto a running job arms the poller');
|
|
await busy.api.loadUpdateConsole();
|
|
assert.equal(busy.liveTimers().length, 1, 'reopening must not stack a second poller');
|
|
assert.equal(busy.timers.length, 2, 'the old timer was cleared rather than leaked: ' + JSON.stringify(busy.timers.map(t => t.cleared)));
|
|
});
|
|
|
|
test('nothing invented: the only toast channel this page may use is showNotification', () => {
|
|
assert.doesNotMatch(source, /showToast/, 'window.showToast does not exist on this page');
|
|
assert.match(source, /typeof showNotification === 'function'/);
|
|
assert.match(source, /window\.applyRBACToUI === 'function'/,
|
|
'optional globals must be probed before they are called');
|
|
});
|
|
|
|
test('server strings cannot become markup, in text or in an attribute', async () => {
|
|
const hostile = '危险 <script>alert(1)</script> "quoted" &more';
|
|
const h = await harness({
|
|
status: statusOf({
|
|
root: '/srv/"><img src=x onerror=alert(1)>/x',
|
|
subject: hostile,
|
|
remoteSubject: hostile,
|
|
blockingChanges: [{ path: 'a"><script>steal()</script>.go', status: 'modified "m"', protected: false }],
|
|
localChanges: [{ path: 'roles/"><svg onload=alert(1)>.md', status: 'modified', protected: true }],
|
|
incoming: [{ commit: 'c"><i>', subject: hostile }],
|
|
incomingTotal: 1,
|
|
checkError: 'fetch failed on <b>remote</b> "origin"',
|
|
}),
|
|
});
|
|
const html = h.html();
|
|
assert.doesNotMatch(html, /<script/, 'a commit subject became a script tag');
|
|
assert.doesNotMatch(html, /<img\b|<svg\b|<b>/, 'markup from server data reached the DOM');
|
|
assert.match(html, /危险 <script>alert\(1\)<\/script> "quoted"/,
|
|
'escapeHtml leaves quotes alone inside text, which is correct and must not be double escaped');
|
|
assert.match(html, /&more/, 'the ampersand must be escaped exactly once');
|
|
|
|
// Attribute integrity: one raw double quote inside a value would close it early and let the
|
|
// rest of the string become attributes of the element.
|
|
['title', 'class', 'onclick', 'onchange', 'data-require-permission'].forEach(attr => {
|
|
const opened = (html.match(new RegExp(attr + '="', 'g')) || []).length;
|
|
const closed = (html.match(new RegExp(attr + '="[^"]*"', 'g')) || []).length;
|
|
assert.equal(opened, closed, 'a ' + attr + ' attribute was closed by an unescaped quote');
|
|
});
|
|
// Counting matched attribute pairs is not enough on its own: a value that ends early still
|
|
// leaves the open and close counts equal. Two hostile strings have to appear fully escaped
|
|
// inside their title attribute - that is the proof escapeAttr, not just escapeHtml, guarded it.
|
|
assert.match(html, /title="a"><script>steal\(\)<\/script>\.go"/,
|
|
'the blocking file path went into an attribute without quote escaping');
|
|
assert.match(html, /title="\/srv\/"><img src=x onerror=alert\(1\)>\/x"/,
|
|
'the install root went into an attribute without quote escaping');
|
|
assert.doesNotMatch(html, /title="[^"]*"[^ >]/, 'a title attribute was closed early by server data');
|
|
const titles = html.match(/title="[^"]*"/g) || [];
|
|
assert.ok(titles.length >= 8, 'these assertions rely on title attributes being emitted: ' + titles.length);
|
|
titles.forEach(t => assert.ok(!/<|>/.test(t), 'a title value still contains markup: ' + t));
|
|
|
|
// Every emitted handler must still be executable as written: an injection into an attribute
|
|
// would break the statement instead.
|
|
const found = h.extractHandlers(html);
|
|
assert.ok(found.onclick.length && found.onchange.length, 'handlers must still render under hostile data');
|
|
found.onclick.concat(found.onchange).forEach(text => {
|
|
assert.doesNotMatch(text, /"/, 'handler text carries a raw quote: ' + text);
|
|
assert.equal(typeof vm.runInContext('(function(){' + text + '})', h.sandbox), 'function',
|
|
'the emitted handler is not valid JS: ' + text);
|
|
});
|
|
['startUpdateApply', 'checkForUpdates', 'rollbackUpdate', 'updateRestartChoiceChanged']
|
|
.forEach(name => assert.ok(found.onclick.concat(found.onchange).some(t => t.startsWith(name + '(')),
|
|
name + ' must stay wired'));
|
|
});
|
|
|
|
test('the rendered handlers are executed, and they only hit the documented endpoints', async () => {
|
|
const h = await harness();
|
|
const found = h.extractHandlers(h.html());
|
|
const texts = found.onchange.concat(found.onclick);
|
|
assert.ok(texts.length >= 4, 'expected the console to emit handlers, got ' + texts.length);
|
|
texts.forEach(t => assert.ok(HANDLES.includes(t.split('(')[0]), 'unknown handler wired into the page: ' + t));
|
|
|
|
h.calls.length = 0;
|
|
h.fire('checkForUpdates');
|
|
await h.flush();
|
|
assert.equal(h.calls[0].method, 'POST');
|
|
assert.equal(h.calls[0].url, '/api/system/update/check');
|
|
assert.equal(h.calls[0].body, undefined, 'the check endpoint takes no body');
|
|
|
|
h.vars.setBusy(false);
|
|
h.calls.length = 0;
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
assert.deepEqual(h.calls.map(c => c.url),
|
|
['/api/system/update/apply', '/api/system/update/job'],
|
|
'apply posts once, then the progress read goes to the job endpoint');
|
|
assert.equal(h.calls[0].method, 'POST');
|
|
assert.equal(h.calls[1].method, 'GET');
|
|
});
|
|
|
|
// The page now asks the remote by itself, once, so the operator arrives at an answer rather
|
|
// than at a button. These pin the politeness of that: one check per session, only for a tree
|
|
// that can be updated, never on top of a running job - and the copy must describe what the
|
|
// code actually does.
|
|
test('opening the page schedules exactly one deferred remote check', async () => {
|
|
const h = await harness({
|
|
status: statusOf({ installed: true }),
|
|
checkedStatus: statusOf({ behind: 3, incomingTotal: 3, updateAvailable: true }),
|
|
});
|
|
assert.deepStrictEqual(h.deferredTimers(), [1200], 'one deferred check must be queued');
|
|
h.fireDeferred();
|
|
await h.flush();
|
|
const checks = h.calls.filter(c => c.method === 'POST' && c.url === '/api/system/update/check');
|
|
assert.strictEqual(checks.length, 1, 'the deferred timer must ask the remote exactly once');
|
|
assert.match(h.html(), /3/, 'the answer must be on screen without another click');
|
|
|
|
// Re-entering the page in the same session does not go back to the remote again.
|
|
h.api.loadUpdateConsole();
|
|
await h.flush();
|
|
assert.deepStrictEqual(h.deferredTimers(), [], 'the auto check is once per session, not per render');
|
|
assert.strictEqual(h.vars.autoCheckDone(), true);
|
|
});
|
|
|
|
test('a tree that cannot be updated is never phoned home for', async () => {
|
|
const h = await harness({ status: statusOf({ installed: false }) });
|
|
assert.deepStrictEqual(h.deferredTimers(), [], 'no auto check when this is not a git installation');
|
|
assert.strictEqual(h.calls.filter(c => c.url === '/api/system/update/check').length, 0);
|
|
});
|
|
|
|
test('an update already running is not disturbed by an auto check', async () => {
|
|
const h = await harness({ status: statusOf({ installed: true }), job: runningJob });
|
|
assert.deepStrictEqual(h.deferredTimers(), [], 'a running job is already the story on screen');
|
|
assert.strictEqual(h.vars.autoCheckDone(), false, 'a skipped check must not spend the session quota');
|
|
});
|
|
|
|
test('the hint describes the automatic check instead of denying any network use', () => {
|
|
for (const [label, dict] of [['zh-CN', zh], ['en-US', en], ['ru-RU', ru]]) {
|
|
const text = dict.update.notChecked;
|
|
assert.ok(text, label + ' lost the notChecked copy');
|
|
assert.doesNotMatch(text, /不会联网|makes no network call|no network call|не выходит в сеть|без обращения к сети/,
|
|
label + ' still promises the page never touches the network');
|
|
assert.match(text, /自动|automatic|automatically|автоматическ/, label + ' must mention the automatic check');
|
|
}
|
|
});
|
|
|
|
test('the source section shows what is in effect, and saving posts the one address', async () => {
|
|
const h = await harness({ status: statusOf({ installed: true }), source: configuredSource });
|
|
assert.match(h.html(), /https:\/\/github\.com\/Sycun\/CyberStrikeAI\.git/,
|
|
'the effective address must be visible');
|
|
assert.match(h.html(), /update-source-url/, 'the editable address field must be there');
|
|
assert.match(h.html(), /update-source-save-btn/);
|
|
|
|
h.runHandler("updateSourceRepoChanged('https://gitlab.com/mirror/csai.git')");
|
|
await h.fire('saveUpdateSource');
|
|
await h.flush();
|
|
const save = h.calls.find(c => c.url === '/api/system/update/source');
|
|
assert.ok(save, 'saving must hit the source endpoint');
|
|
assert.deepEqual(JSON.parse(save.body), { repo: 'https://gitlab.com/mirror/csai.git' },
|
|
'the save carries exactly the one address, nothing else');
|
|
assert.ok(h.toasts.some(t => t.type === 'success'), 'a saved source must be confirmed');
|
|
});
|
|
|
|
test('a finished check must not leave the source form frozen (the button stays clickable)', async () => {
|
|
// 线上实测过的形态:打开页面 → 自动检查在忙期里重绘 → 检查结束后不再重绘,
|
|
// 「保存更新源」带着 disabled + data-state-disabled 留在 DOM 上——之后点击永远没有反应,
|
|
// 服务端连请求都收不到。这里把用户的整条动作链原样跑一遍。
|
|
const h = await harness({ status: statusOf({ installed: true }), source: configuredSource });
|
|
|
|
h.fireDeferred(); // 打开页面 1.2 秒后的那次自动检查
|
|
assert.match(h.html(), /update-source-save-btn" disabled/,
|
|
'the form must visibly lock while a check is in flight');
|
|
await h.flush();
|
|
assert.match(h.html(), /已是最新|落后/, 'precondition: the automatic check finished');
|
|
assert.doesNotMatch(h.html(), /update-source-save-btn" disabled/,
|
|
'a finished check must re-render the form with the button enabled again');
|
|
|
|
// 用户的下一步:清空地址 → 点保存 → 请求必须真的发出去
|
|
h.runHandler("updateSourceRepoChanged('')");
|
|
await h.fire('saveUpdateSource');
|
|
await h.flush();
|
|
const save = h.calls.find(c => c.url === '/api/system/update/source');
|
|
assert.ok(save, 'saving right after a check must reach the server');
|
|
assert.deepEqual(JSON.parse(save.body), { repo: '' });
|
|
assert.doesNotMatch(h.html(), /update-source-save-btn" disabled/,
|
|
'after saving the button must be ready for the next action, not frozen');
|
|
});
|
|
|
|
test('a second save is not swallowed by a stale busy flag', async () => {
|
|
const h = await harness({ status: statusOf({ installed: true }), source: configuredSource });
|
|
h.runHandler("updateSourceRepoChanged('https://gitlab.com/mirror/csai.git')");
|
|
await h.fire('saveUpdateSource');
|
|
await h.flush();
|
|
await h.fire('saveUpdateSource');
|
|
await h.flush();
|
|
const saves = h.calls.filter(c => c.url === '/api/system/update/source');
|
|
assert.equal(saves.length, 2, 'the second save must also reach the server');
|
|
});
|
|
|
|
test('clearing the address is the way back to the official repository', async () => {
|
|
const h = await harness({ status: statusOf({ installed: true }), source: configuredSource });
|
|
h.runHandler('updateSourceRepoChanged("")');
|
|
await h.fire('saveUpdateSource');
|
|
await h.flush();
|
|
const save = h.calls.find(c => c.url === '/api/system/update/source');
|
|
assert.deepEqual(JSON.parse(save.body), { repo: '' },
|
|
'an empty address means \"use the official repository again\", not an error');
|
|
});
|
|
|
|
test('a refused save shows the server reason instead of a generic failure', async () => {
|
|
const refusal = '仓库地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)';
|
|
const h = await harness({
|
|
status: statusOf({ installed: true }),
|
|
source: configuredSource,
|
|
responses: {
|
|
'POST /api/system/update/source': [{ status: 400, body: { error: refusal } }],
|
|
},
|
|
});
|
|
h.runHandler("updateSourceRepoChanged('ext::sh -c true')");
|
|
await h.fire('saveUpdateSource');
|
|
await h.flush();
|
|
assert.match(h.html(), /不合法/, 'the refusal must be on screen');
|
|
assert.ok(h.toasts.some(t => t.type === 'error' && t.msg.includes('不合法')), 'and in a toast');
|
|
assert.match(h.html(), /ext::sh -c true/, 'the typed value must survive the failed save');
|
|
});
|
|
|
|
test('the adopt entry appears only for a directory that is not a git installation', async () => {
|
|
// No source configured either: the official repository is the default, so connecting is
|
|
// always available for a non-git directory.
|
|
const notGit = await harness({ status: statusOf({ installed: false }), source: emptySource });
|
|
assert.match(notGit.html(), /previewAdoptSource\(\)/, 'a non-git tree must offer connecting even with the default source');
|
|
|
|
const gitTree = await harness({ status: statusOf({ installed: true }), source: emptySource });
|
|
assert.doesNotMatch(gitTree.html(), /previewAdoptSource\(\)/, 'a git installation updates, it does not adopt');
|
|
});
|
|
|
|
test('adopt previews first, and confirming carries the counts into the dialog', async () => {
|
|
const h = await harness({ status: statusOf({ installed: false }), source: configuredSource });
|
|
await h.fire('previewAdoptSource');
|
|
await h.flush();
|
|
assert.match(h.html(), /abc1234/, 'the target commit must be on screen');
|
|
assert.match(h.html(), /web\/static\/js\/a\.js/, 'the files to be replaced must be listed');
|
|
assert.match(h.html(), /roles\/我的角色\.yaml/, 'and the operator content that is kept');
|
|
|
|
await h.fire('confirmAdoptSource');
|
|
await h.flush();
|
|
assert.strictEqual(h.confirms.length, 1, 'connecting must ask first');
|
|
assert.match(h.confirms[0], /42/, 'the dialog counts what will happen');
|
|
const adopt = h.calls.find(c => c.url === '/api/system/update/adopt' && c.method === 'POST' && c.body && c.body.includes('"confirm":true'));
|
|
assert.ok(adopt, 'confirming must post to adopt');
|
|
assert.deepEqual(JSON.parse(adopt.body), { confirm: true, restart: false });
|
|
assert.ok(h.vars.getPollTimer(), 'the job it started must be polled');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 待生效的二进制:常驻横幅 + 立即重启 + 守侧重连。这一组是"忘勾重启也有救、重启后页面
|
|
// 自己回来"的直接门禁——任何一处退化成"界面没反应"或"要手动刷新",这里都会红。
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('a binary swap that never got a restart is announced with a way to activate it', async () => {
|
|
const h = await harness({ flags: { needsRestart: true, binaryBuiltAt: '2026-10-07T22:23:04+08:00' } });
|
|
assert.match(h.html(), /update-restart-banner/,
|
|
'the tree already says the new commit while the process still runs the old build; that must be impossible to miss');
|
|
assert.match(h.html(), /立即重启服务/);
|
|
assert.match(h.html(), /data-require-permission="update:apply"/);
|
|
assert.match(h.html(), /2026/, 'the banner names when the binary on disk was built');
|
|
assert.match(h.html(), /\d{1,2}\/\d{1,2}\/\d{4}/,
|
|
'the build time must render as a date: i18next escapes / unless the console turns that off');
|
|
assert.doesNotMatch(h.html(), ///, 'an interpolated value must not arrive pre-escaped');
|
|
assert.match(h.html(), /需要重启才会运行新版本/);
|
|
assert.match(source, /interpolation: \{ escapeValue: false \}/,
|
|
'the console has to stop i18next from HTML-escaping interpolated values before escapeHtml runs');
|
|
|
|
// A tree without a commit (not a git install) must still read like a sentence.
|
|
const noCommit = await harness({
|
|
flags: { needsRestart: true, binaryBuiltAt: '2026-10-07T22:23:04+08:00' },
|
|
status: statusOf({ installed: false, commit: '' }),
|
|
});
|
|
assert.doesNotMatch(noCommit.html(), /已更新到 -/, 'no commit must not read as "updated to -"');
|
|
assert.match(noCommit.html(), /磁盘上的二进制已换新/, 'the no-commit sentence must stand on its own');
|
|
|
|
const clean = await harness({});
|
|
assert.doesNotMatch(clean.html(), /update-restart-banner/,
|
|
'a process running the binary that is on disk has nothing to restart for');
|
|
});
|
|
|
|
test('restart now asks first, posts once, and hands the page to the watchdog', async () => {
|
|
const h = await harness({
|
|
flags: { needsRestart: true },
|
|
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
|
|
});
|
|
h.setConfirm(false);
|
|
h.calls.length = 0;
|
|
h.fire('startRestartNow');
|
|
await h.flush();
|
|
assert.equal(h.confirms.length, 1, 'standing the service down is confirmed, never implied');
|
|
assert.match(h.confirms[0], /守护/, 'the confirm has to say what brings it back: ' + h.confirms[0]);
|
|
assert.deepEqual(h.calls, [], 'a cancelled restart must not post');
|
|
assert.equal(h.vars.getWatchdogTimer(), null, 'nothing may be watched for a restart nobody asked for');
|
|
|
|
h.setConfirm(true);
|
|
h.vars.setBusy(false);
|
|
h.calls.length = 0;
|
|
h.fire('startRestartNow');
|
|
await h.flush();
|
|
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['POST /api/system/update/restart']);
|
|
assert.match(h.html(), /正在重启服务/, 'the console becomes the recovery view');
|
|
assert.ok(h.vars.getWatchdogTimer(), 'the watchdog must be armed once the stand-down is accepted');
|
|
// Exactly one watcher: the console is replaced by the recovery view, so there is no second
|
|
// button to press - and re-arming must not stack timers anyway.
|
|
assert.equal(h.timers.filter(t => !t.cleared && t.ms === 2000).length, 1);
|
|
});
|
|
|
|
test('a refused restart is reported with the server sentence and arms nothing', async () => {
|
|
const refusal = '磁盘上的二进制与当前进程一致,没有待生效的版本';
|
|
const h = await harness({
|
|
flags: { needsRestart: true },
|
|
responses: { 'POST /api/system/update/restart': [{ status: 409, body: { error: refusal } }] },
|
|
});
|
|
await h.api.startRestartNow();
|
|
await h.flush();
|
|
assert.match(h.toasts[h.toasts.length - 1].msg, /没有待生效的版本/, 'the server sentence is the one to show');
|
|
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
|
|
assert.equal(h.vars.getWatchdogTimer(), null, 'a refusal must not start watching for a boot that is not coming');
|
|
assert.equal(h.location.replaced.length, 0);
|
|
});
|
|
|
|
test('the watchdog reloads only when the NEW process answers, never while the old one still does', async () => {
|
|
const h = await harness({
|
|
flags: { needsRestart: true },
|
|
probes: ['ok', 'down', 'down', 'unauthorized'],
|
|
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
|
|
});
|
|
await h.api.startRestartNow();
|
|
await h.flush();
|
|
assert.equal(h.location.replaced.length, 0);
|
|
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.equal(h.location.replaced.length, 0, 'a 200 from the old process is not "it is back"');
|
|
|
|
await h.tick();
|
|
await h.flush();
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.equal(h.location.replaced.length, 0, 'a dead service is not a recovered one');
|
|
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.equal(h.location.replaced.length, 1,
|
|
'a 401 is the proof: only a new process can fail to know our session');
|
|
assert.match(h.location.replaced[0], /\?restarted=\d+/);
|
|
assert.match(h.location.replaced[0], /#system-update$/);
|
|
assert.equal(h.vars.getWatchdogTimer(), null, 'the page is leaving: the timer must be down');
|
|
});
|
|
|
|
test('a restart that does not come back says so instead of spinning forever', async () => {
|
|
const downCase = await harness({
|
|
flags: { needsRestart: true },
|
|
probes: ['down'],
|
|
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
|
|
});
|
|
await downCase.api.startRestartNow();
|
|
await downCase.flush();
|
|
downCase.vars.expireWatchdog();
|
|
await downCase.tick();
|
|
await downCase.flush();
|
|
assert.match(downCase.html(), /没有守护进程|不会自己启动/, 'the down advice must name the supervision question');
|
|
|
|
const aliveCase = await harness({
|
|
flags: { needsRestart: true },
|
|
probes: ['ok'],
|
|
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
|
|
});
|
|
await aliveCase.api.startRestartNow();
|
|
await aliveCase.flush();
|
|
aliveCase.vars.expireWatchdog();
|
|
await aliveCase.tick();
|
|
await aliveCase.flush();
|
|
assert.equal(aliveCase.vars.getWatchdogSlow(), 'running');
|
|
assert.match(aliveCase.html(), /仍在以旧进程应答|重启似乎没有发生/);
|
|
});
|
|
|
|
test('an update that asked for a restart hands the page to the watchdog instead of reporting a failed poll', async () => {
|
|
const h = await harness({
|
|
flags: { supervised: true },
|
|
responses: {
|
|
'GET /api/system/update/job': [
|
|
{ status: 200, body: { job: runningJob } },
|
|
{ status: 200, body: { job: succeededJob } },
|
|
],
|
|
},
|
|
probes: ['unauthorized'],
|
|
});
|
|
// In a real browser the pre-ticked box makes this true; the harness reads the element.
|
|
h.sandbox.document.getElementById('update-restart-choice').checked = true;
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
const apply = h.calls.find(c => c.url === '/api/system/update/apply');
|
|
assert.deepEqual(JSON.parse(apply.body), { restart: true },
|
|
'with launchd/systemd detected, one click is the whole update, restart included');
|
|
|
|
// The apply already polled once (running); this tick reads the finished job, which asked
|
|
// for the restart - that is the hand-off point.
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.match(h.html(), /正在重启服务/, 'the succeeded-with-restart job hands over to the recovery view');
|
|
assert.deepEqual(h.toasts.filter(t => t.type === 'error'), [],
|
|
'the process going away by request is not a polling failure to complain about');
|
|
assert.ok(h.vars.restartPending());
|
|
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.equal(h.location.replaced.length, 1);
|
|
});
|
|
|
|
test('a job poll that dies while the restart is in flight is not a failure', async () => {
|
|
// The realistic race: the process exits (and stops answering the job poll) before the
|
|
// page's next poll lands. That is the restart happening, not a broken console.
|
|
const h = await harness({
|
|
flags: { supervised: true },
|
|
rejects: ['GET /api/system/update/job'],
|
|
probes: ['unauthorized'],
|
|
});
|
|
h.sandbox.document.getElementById('update-restart-choice').checked = true;
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
assert.match(h.html(), /正在重启服务/, 'a dead poll while a restart is requested means the restart is in flight');
|
|
assert.deepEqual(h.toasts.filter(t => t.type === 'error'), [], 'and it must not be toasted as a failure');
|
|
|
|
await h.tick();
|
|
await h.flush();
|
|
assert.equal(h.location.replaced.length, 1);
|
|
});
|
|
|
|
test('an interrupted rollback offers recovery and blocks a new update', async () => {
|
|
const h = await harness({ status: statusOf({ rollbackPending: true, hasRollback: true }) });
|
|
assert.match(h.html(), /上一次回滚尚未完成/);
|
|
assert.match(h.html(), /update-rollback-btn" data-require-permission="update:apply"/);
|
|
assert.match(h.html(), /update-apply-btn" disabled data-state-disabled="true"/);
|
|
h.calls.length = 0;
|
|
h.fire('startUpdateApply');
|
|
await h.flush();
|
|
assert.equal(h.calls.some(c => c.url === '/api/system/update/apply'), false);
|
|
});
|