Files
CyberStrikeAI/web/static/js/update-ui.test.cjs
T
Sycun 94a18263b9 feat: make the update source one repository address, the official repo by default
The update source used to be three things - a remote name or an address, plus an
optional branch - and unset it followed whatever this directory's remotes and
upstream happened to say. That asked people to understand git topology before
they could update, and the person it was built for could not tell what "follow
the remote this tree tracks" meant. It is now one field: config.yaml update.repo,
an address, empty meaning the official repository
(https://github.com/AIPentest/CyberStrikeAI.git). Point it at a mirror (GitHub is
hard to reach in some places) or at your own fork and that is the whole
configuration.

The branch nothing is any more: an update follows the source repository's own
default branch, resolved from the source itself (ls-remote --symref HEAD), so a
fork whose default is not "main" still works and nobody has to name a branch.
Remote names, the mine/origin/upstream ranking, @{upstream} tracking and the
adopt flow's branch argument are all gone with it.

The console page loses two input fields: the source section is one address, a
hint that empty means the official repository, and a save button; clearing the
address is the way back to the default. The install facts drop the remote name,
the check section shows the source's default branch once the network answer
arrives, and the adopt entry no longer requires saving an address first - the
official default makes connecting a tarball install a zero-configuration action.

The CLI now reads the same update.repo field the page saves (LoadUpdateRepo reads
just that field, without validating the rest of the file - a broken unrelated
section must not block the path that fixes the tree), so the keyboard and the
button cannot update from different repositories. -check-update prints the source
it is about to use.

Tests: the source suite now pins the default (official), the single-address save
(including the empty-string reset), and that the followed branch comes from the
source's own default branch rather than a hardcoded "main"; the console suite
posts the one field and renders the default hint; docs, config.example.yaml and
upgrade.sh describe the one-field story.
2026-10-08 13:46:14 +08:00

1375 lines
69 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
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, '&quot;').replace(/'/g, '&#39;');
},
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 / -> &#x2F;); 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\//g, '&#x2F;')
: 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('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, /危险 &lt;script&gt;alert\(1\)&lt;\/script&gt; "quoted"/,
'escapeHtml leaves quotes alone inside text, which is correct and must not be double escaped');
assert.match(html, /&amp;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&quot;&gt;&lt;script&gt;steal\(\)&lt;\/script&gt;\.go"/,
'the blocking file path went into an attribute without quote escaping');
assert.match(html, /title="\/srv\/&quot;&gt;&lt;img src=x onerror=alert\(1\)&gt;\/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('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(), /&#x2F;/, '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);
});