mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 06:42:52 +08:00
293 lines
15 KiB
JavaScript
293 lines
15 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { spawnSync } = require('node:child_process');
|
|
|
|
const root = path.resolve(__dirname, '..');
|
|
const source = fs.readFileSync(path.join(root, 'root/usr/lib/minigate/update.sh'), 'utf8');
|
|
const functions = source.slice(source.indexOf('json_escape()'), source.lastIndexOf('\ncase "${1:-status}"'));
|
|
const version = source.match(/^CURRENT_VERSION="([^"]+)"/m)[1];
|
|
const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
|
|
function shell(body, env = {}) {
|
|
const result = spawnSync('/bin/sh', ['-c', body], { encoding: 'utf8', env: { ...process.env, ...env } });
|
|
assert.equal(result.error, undefined);
|
|
return result;
|
|
}
|
|
|
|
function backend(body, options = {}) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-test-'));
|
|
const fixture = { tag_name: `v${options.latest || version}`, name: 'minigate', body: 'Update notes' };
|
|
fs.writeFileSync(path.join(dir, 'fixture.json'), JSON.stringify(fixture));
|
|
fs.writeFileSync(path.join(dir, 'jsonfilter.cjs'), `
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
try {
|
|
const value = JSON.parse(fs.readFileSync(args[args.indexOf('-i')+1], 'utf8'));
|
|
const result = value[args[args.indexOf('-e')+1].slice(2)];
|
|
if (result !== undefined) console.log(result);
|
|
} catch (_) { process.exitCode = 1; }
|
|
`);
|
|
const result = shell(`
|
|
CURRENT_VERSION=${quote(version)}
|
|
STATE_CURRENT="$CURRENT_VERSION"
|
|
STATE_FILE="$TEST_DIR/state.json"
|
|
RELEASE_FILE="$TEST_DIR/release.json"
|
|
LAST_CHECK_FILE="$TEST_DIR/last-check"
|
|
LOCK_FILE="$TEST_DIR/lock"
|
|
TMP_ROOT="$TEST_DIR/work"
|
|
RUN_LOG="$TEST_DIR/log"
|
|
API_URL=https://example.invalid/latest
|
|
${functions}
|
|
jsonfilter() { "$NODE" "$TEST_DIR/jsonfilter.cjs" "$@"; }
|
|
flock() {
|
|
printf '%s\n' "$*" >> "$TEST_DIR/flocks"
|
|
[ "$1" = "-u" ] && return 0
|
|
test "\${FLOCK_BUSY:-0}" = 0
|
|
}
|
|
download_file() {
|
|
printf 'fetch\\n' >> "$TEST_DIR/fetches"
|
|
test "\${FETCH_FAIL:-0}" = 0 || return 1
|
|
cp "$TEST_DIR/fixture.json" "$2"
|
|
}
|
|
backup_program() { echo 'UNEXPECTED INSTALL' >&2; exit 99; }
|
|
${body}
|
|
`, { TEST_DIR: dir, NODE: process.execPath, ...options.env });
|
|
return {
|
|
...result,
|
|
state: fs.existsSync(path.join(dir, 'state.json')) ? JSON.parse(fs.readFileSync(path.join(dir, 'state.json'))) : null,
|
|
fetches: fs.existsSync(path.join(dir, 'fetches')) ? fs.readFileSync(path.join(dir, 'fetches'), 'utf8').trim().split('\n').length : 0,
|
|
flocks: fs.existsSync(path.join(dir, 'flocks')) ? fs.readFileSync(path.join(dir, 'flocks'), 'utf8').trim().split('\n') : []
|
|
};
|
|
}
|
|
|
|
test('date versions compare numeric day and same-day sequence, including legacy version', () => {
|
|
for (const [left, right, newer] of [
|
|
['2026.9.14', '1.3.11', true], ['2026.9.14', '2026.9.14', false],
|
|
['2026.9.14-1', '2026.9.14', true], ['2026.9.14-10', '2026.9.14-2', true],
|
|
['2026.9.14-2', '2026.9.14-10', false], ['2026.9.15', '2026.9.14-99', true],
|
|
['2026.10.1', '2026.9.30-9', true], ['2027.1.1', '2026.12.31', true],
|
|
['1.3.11', '2026.9.14', false]
|
|
]) {
|
|
assert.equal(shell(`${functions}\nversion_newer ${quote(left)} ${quote(right)}`).status, newer ? 0 : 1, `${left} > ${right}`);
|
|
}
|
|
});
|
|
|
|
test('reject malformed versions and shell syntax in tags', () => {
|
|
for (const value of ['2026.9.14', '2026.9.14-1', '2026.9.14-10']) {
|
|
assert.equal(shell(`${functions}\nvalid_version ${quote(value)}`).status, 0, value);
|
|
}
|
|
for (const value of ['', 'latest', '1.3.11', '2026.13.14', '2026.9.0', '2026.9.14-0', '2026.9.14-01', '2026.9.14;id', '../2026.9.14']) {
|
|
assert.equal(shell(`${functions}\nvalid_version ${quote(value)}`).status, 1, value);
|
|
}
|
|
});
|
|
|
|
test('opening overview checks once then reuses a successful ten-minute cache', () => {
|
|
const result = backend('run_auto_check >/dev/null; run_auto_check');
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.fetches, 1);
|
|
assert.equal(result.state.status, 'up_to_date');
|
|
});
|
|
|
|
test('manual check, expired cache and changed installed version force a fresh check', () => {
|
|
for (const next of ['run_check', 'echo 1 > "$LAST_CHECK_FILE"; run_auto_check', 'CURRENT_VERSION=2020.1.1; STATE_CURRENT=$CURRENT_VERSION; run_auto_check']) {
|
|
const result = backend(`run_check >/dev/null; ${next}`);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.fetches, 2);
|
|
}
|
|
});
|
|
|
|
test('success/error/running state is not reused as a successful check cache', () => {
|
|
for (const state of ['success', 'error', 'installing']) {
|
|
const result = backend(`run_check >/dev/null; write_state ${state} 100 "$CURRENT_VERSION" false false true done; run_auto_check`);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.state.status, 'up_to_date');
|
|
assert.equal(result.fetches, 2);
|
|
}
|
|
});
|
|
|
|
test('busy updater is reported without downloading or starting another update', () => {
|
|
const result = backend('write_state installing 75 "2027.1.1" true true true busy; FLOCK_BUSY=1; run_auto_check');
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.state.status, 'installing');
|
|
assert.equal(result.fetches, 0);
|
|
});
|
|
|
|
test('apply releases its lock even when the locked operation returns early', () => {
|
|
const result = backend('run_apply_locked() { return 7; }; run_apply "$CURRENT_VERSION"');
|
|
assert.equal(result.status, 7, result.stderr);
|
|
assert.deepEqual(result.flocks, ['-n 9', '-u 9']);
|
|
});
|
|
|
|
test('long-running services are started with the update lock descriptor closed', () => {
|
|
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
|
|
assert.match(installer, /\/etc\/init\.d\/minigate start 9>&-/);
|
|
assert.match(source, /\/etc\/init\.d\/minigate start >> "\$RUN_LOG" 2>&1 9>&-/);
|
|
assert.equal((source.match(/\/etc\/init\.d\/uhttpd restart >> "\$RUN_LOG" 2>&1 9>&-/g) || []).length, 2);
|
|
});
|
|
|
|
test('network failure is recoverable and never starts installation', () => {
|
|
const result = backend('run_auto_check', { env: { FETCH_FAIL: '1' } });
|
|
assert.equal(result.status, 1);
|
|
assert.equal(result.state.status, 'error');
|
|
assert.equal(result.state.running, false);
|
|
});
|
|
|
|
test('install requires an explicitly confirmed version and rejects a changed release', () => {
|
|
const missing = backend('run_apply ""');
|
|
assert.equal(missing.status, 1);
|
|
assert.equal(missing.fetches, 0);
|
|
const changed = backend('run_apply "2027.1.1"', { latest: '2027.1.2' });
|
|
assert.equal(changed.status, 1);
|
|
assert.equal(changed.state.latest, '2027.1.2');
|
|
assert.equal(changed.state.available, true);
|
|
assert.equal(changed.state.running, false);
|
|
assert.match(changed.state.message, /重新/);
|
|
});
|
|
|
|
function overview() {
|
|
const lua = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/general.lua'), 'utf8');
|
|
const js = lua.slice(lua.indexOf('var _updatePolling'), lua.indexOf('var limitSel')).replace(/\]\] \.\. (\w+) \.\. \[\[/g, '$1');
|
|
const elements = new Map();
|
|
const element = id => {
|
|
if (!elements.has(id)) elements.set(id, { style: {}, className: '', textContent: '', disabled: false, checked: false, hidden: false, setAttribute() {}, focus() { document.activeElement = this; } });
|
|
return elements.get(id);
|
|
};
|
|
const listeners = {};
|
|
const document = {
|
|
body: { style: { overflow: 'auto' } }, activeElement: null,
|
|
getElementById: element, querySelector: () => null, addEventListener: (name, handler) => { listeners[name] = handler; }
|
|
};
|
|
const buttons = ['close', 'mg-update-modal-notes', 'cancel', 'mg-update-confirm'].map(element);
|
|
const uninstallItems = ['mg-uninstall-close', 'mg-uninstall-purge', 'mg-uninstall-cancel', 'mg-uninstall-confirm'].map(element);
|
|
element('mg-update-modal').querySelectorAll = () => buttons;
|
|
element('mg-update-modal').contains = item => buttons.includes(item);
|
|
element('mg-uninstall-modal').querySelectorAll = () => uninstallItems;
|
|
element('mg-uninstall-modal').contains = item => uninstallItems.includes(item);
|
|
const requests = [], timers = [];
|
|
const context = vm.createContext({ document, mgColor: value => value, location: { reload() {} }, setTimeout: callback => timers.push(callback), XHR: {
|
|
get: (url, data, callback) => requests.push({ method: 'GET', url, data, callback }),
|
|
post: (url, data, callback) => requests.push({ method: 'POST', url, data, callback })
|
|
} });
|
|
vm.runInContext(js, context);
|
|
const available = { status: 'available', current: version, latest: '2027.1.1', available: true, success: true, release_title: 'New release', release_notes: '<script>bad()</script>\n中文说明' };
|
|
return { context, document, element, requests, timers, available, buttons, uninstallItems, listeners };
|
|
}
|
|
|
|
test('overview automatically checks but does not install', () => {
|
|
const page = overview();
|
|
const lua = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/general.lua'), 'utf8');
|
|
assert.match(lua, /mgAutoCheckUpdate\(\);\s*<\/script>/);
|
|
page.context.mgAutoCheckUpdate();
|
|
assert.equal(page.requests.length, 1);
|
|
assert.equal(page.requests[0].method, 'GET');
|
|
assert.equal(page.requests[0].url, 'xu');
|
|
page.requests[0].callback(null, page.available);
|
|
page.context.mgApplyUpdate();
|
|
assert.equal(page.requests.length, 1);
|
|
assert.equal(page.element('mg-update-modal-notes').textContent, page.available.release_notes);
|
|
page.context.mgCloseUpdateDialog();
|
|
page.context.mgStartUpdate();
|
|
assert.equal(page.requests.length, 1);
|
|
assert.equal(page.document.body.style.overflow, 'auto');
|
|
});
|
|
|
|
test('confirm installs exactly the shown version, ignores double clicks and preserves retry notes', () => {
|
|
const page = overview();
|
|
page.context.mgRenderUpdate(page.available);
|
|
page.context.mgApplyUpdate();
|
|
page.context.mgRenderUpdate({ ...page.available, latest: '2027.1.2' });
|
|
page.context.mgStartUpdate();
|
|
page.context.mgStartUpdate();
|
|
assert.equal(page.requests.length, 1);
|
|
assert.equal(page.requests[0].method, 'POST');
|
|
assert.equal(page.requests[0].data.version, '2027.1.1');
|
|
page.requests[0].callback(null, { success: false, message: 'Retry' });
|
|
page.context.mgApplyUpdate();
|
|
assert.equal(page.element('mg-update-modal-notes').textContent, page.available.release_notes);
|
|
});
|
|
|
|
test('dialog traps keyboard focus and Escape cancels without installation', () => {
|
|
const page = overview();
|
|
page.context.mgRenderUpdate(page.available);
|
|
page.context.mgApplyUpdate();
|
|
page.buttons.at(-1).focus();
|
|
page.listeners.keydown({ key: 'Tab', preventDefault() {} });
|
|
assert.equal(page.document.activeElement, page.buttons[0]);
|
|
page.listeners.keydown({ key: 'Tab', shiftKey: true, preventDefault() {} });
|
|
assert.equal(page.document.activeElement, page.buttons.at(-1));
|
|
page.listeners.keydown({ key: 'Escape', preventDefault() {} });
|
|
assert.equal(page.element('mg-update-modal').className, 'mg-modal');
|
|
assert.equal(page.requests.length, 0);
|
|
});
|
|
|
|
test('uninstall defaults to preserving data and cancel, Escape and backdrop never submit', () => {
|
|
const page = overview();
|
|
page.element('mg-uninstall-purge').checked = true;
|
|
page.context.mgOpenUninstallDialog();
|
|
assert.equal(page.element('mg-uninstall-purge').checked, false);
|
|
assert.equal(page.element('mg-uninstall-modal').className, 'mg-modal is-open');
|
|
assert.match(page.element('mg-uninstall-detail').textContent, /默认保留/);
|
|
page.listeners.keydown({ key: 'Escape', preventDefault() {} });
|
|
assert.equal(page.requests.length, 0);
|
|
|
|
page.context.mgOpenUninstallDialog();
|
|
page.element('mg-uninstall-modal').onclick({ target: page.element('mg-uninstall-modal') });
|
|
assert.equal(page.requests.length, 0);
|
|
page.context.mgOpenUninstallDialog();
|
|
page.context.mgCloseUninstallDialog();
|
|
assert.equal(page.requests.length, 0);
|
|
});
|
|
|
|
test('uninstall submits an explicit purge choice once and redirects only after acceptance', () => {
|
|
const page = overview();
|
|
page.context.mgOpenUninstallDialog();
|
|
page.element('mg-uninstall-purge').checked = true;
|
|
page.context.mgUpdateUninstallChoice();
|
|
assert.equal(page.element('mg-uninstall-confirm').textContent, '彻底卸载');
|
|
page.context.mgStartUninstall();
|
|
page.context.mgStartUninstall();
|
|
assert.equal(page.requests.length, 1);
|
|
assert.equal(page.requests[0].method, 'POST');
|
|
assert.equal(page.requests[0].url, 'du');
|
|
assert.equal(page.requests[0].data.confirm, 'uninstall-minigate');
|
|
assert.equal(page.requests[0].data.purge, '1');
|
|
page.requests[0].callback(null, { success: true });
|
|
assert.match(page.element('mg-uninstall-status').textContent, /返回服务页面/);
|
|
assert.equal(page.timers.length, 1);
|
|
page.timers[0]();
|
|
assert.equal(page.context.location.href, 'services_url');
|
|
});
|
|
|
|
test('a rejected uninstall remains open and can be retried', () => {
|
|
const page = overview();
|
|
page.context.mgOpenUninstallDialog();
|
|
page.context.mgStartUninstall();
|
|
page.requests[0].callback(null, { success: false, message: 'busy' });
|
|
assert.equal(page.element('mg-uninstall-modal').className, 'mg-modal is-open');
|
|
assert.equal(page.element('mg-uninstall-confirm').disabled, false);
|
|
assert.equal(page.element('mg-uninstall-status').textContent, 'busy');
|
|
page.context.mgStartUninstall();
|
|
assert.equal(page.requests.length, 2);
|
|
});
|
|
|
|
test('an old success state never reloads an overview that did not start the update', () => {
|
|
const page = overview();
|
|
page.context.mgRenderUpdate({ ...page.available, status: 'success', available: false });
|
|
assert.equal(page.timers.length, 0);
|
|
});
|
|
|
|
test('build metadata has a consistent version and the actual minigate package identity', () => {
|
|
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
|
|
const build = fs.readFileSync(path.join(root, 'scripts/build-ipk.sh'), 'utf8');
|
|
assert.equal(makefile.match(/^PKG_VERSION:=(.+)$/m)[1], version);
|
|
assert.equal(build.match(/^PKG_VERSION="(.+)"$/m)[1], version);
|
|
assert.match(makefile, /^PKG_NAME:=minigate$/m);
|
|
assert.match(build, /^PKG_NAME="minigate"$/m);
|
|
assert.equal((build.match(/^Version:/gm) || []).length, 1);
|
|
assert.ok(fs.existsSync(path.join(root, `releases/v${version}.md`)));
|
|
});
|