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: '\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`))); });