fix: separate LXC package checks from installation

This commit is contained in:
VAIO73
2026-10-15 03:49:58 +00:00
parent f3add354e6
commit 664ef981e5
18 changed files with 1341 additions and 70 deletions
+19
View File
@@ -67,6 +67,10 @@ EOF
cat >"$MOCK_BIN/python3" <<'EOF'
#!/bin/bash
cat >/dev/null
if [[ "${1:-}" == '-c' ]]; then
[[ "${MOCK_CHECK_FAIL:-0}" != '1' ]]
exit $?
fi
if [[ "${2:-}" == 'protect-update-command' ]]; then
printf '%s' "$UPDATE_COMMAND"
exit 0
@@ -82,6 +86,7 @@ EOF
cat >"$MOCK_BIN/vzdump" <<'EOF'
#!/bin/bash
echo "vzdump $*" >>"$MOCK_LOG"
[[ "${MOCK_BACKUP_FAIL:-0}" != '1' ]]
EOF
chmod +x "$MOCK_BIN"/*
@@ -161,4 +166,18 @@ run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1
[[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater"
run_case checked_missing TARGET=os REQUIRE_CHECKED_OS=1 MOCK_CHECK_FAIL=1
[[ $CASE_RC -eq 4 ]] || fail "unchecked installation was not rejected"
[[ $(count 'pct ') -eq 0 ]] || fail "unchecked installation probed or started a CT"
[[ $(count OS_EXEC) -eq 0 ]] || fail "unchecked installation upgraded packages"
run_case checked_backup_fail TARGET=os REQUIRE_CHECKED_OS=1 MOCK_INITIAL_STATE=stopped BACKUP=1 BACKUP_STORAGE=local MOCK_BACKUP_FAIL=1
[[ $CASE_RC -eq 3 ]] || fail "backup failure was not reported"
[[ $(count OS_EXEC) -eq 0 ]] || fail "backup failure upgraded packages"
[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "backup failure did not restore stopped state"
run_case checked_positive TARGET=os REQUIRE_CHECKED_OS=1
[[ $CASE_RC -eq 0 ]] || fail "positive checked installation failed"
[[ $(count OS_EXEC) -gt 0 ]] || fail "positive checked installation did not use existing OS path"
echo "apply_updates.sh: all deterministic tests passed"
+296
View File
@@ -0,0 +1,296 @@
"""Real job, Flask routes and shared flock; guest transport is isolated."""
import ast
import datetime
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from flask import Flask, jsonify
from flask import request
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import lxc_check_job as job
class CheckOnlyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.folder = Path(self.temp.name)
self.conf = self.folder / '100.conf'
self.conf.write_text('hostname: fixture\n')
self.patches = [patch.object(job, 'LOCK_DIR', self.folder),
patch.object(job, 'CONF_DIR', self.folder),
patch.object(job, 'OCI_REGISTRY', self.folder / 'installed.json'),
patch.object(job.managed_installs, '_lxc_updates_detection_enabled', return_value=True),
patch.object(job.managed_installs, '_get_oci_managed_vmids', return_value={}),
patch.object(job.managed_installs, 'get_active_items', return_value=[])]
for p in self.patches:
p.start()
self.addCleanup(p.stop)
job._jobs.clear()
job._attempts.clear()
self.state = 'stopped'
self.calls = []
self.result = {'result': {'available': True, '_count': 2}, 'entry': {}}
def transport(self, vmid, action, *args, **kwargs):
self.calls.append(action)
if action == 'status':
return 'status: ' + self.state
if action == 'start':
self.state = 'running'
if action in ('stop', 'shutdown'):
self.state = 'stopped'
return ''
def run_check(self, capture_error=None, persistence_error=None, transport=None):
reservation = job.acquire(100)
job._jobs['run'] = dict(vmid=100, state='running', created=time.monotonic())
with patch.object(job, 'command', side_effect=transport or self.transport), \
patch.object(job.lxc_update_observation, 'capture', return_value=self.result,
side_effect=capture_error) as capture, \
patch.object(job.lxc_update_observation, 'discard'), \
patch.object(job.managed_installs, 'store_lxc_update_observation',
return_value=True, side_effect=persistence_error) as store:
job.run(100, 'run', reservation)
return job.get(100, 'run'), capture, store
def test_stopped_check_restores_and_never_installs(self):
result, capture, store = self.run_check()
self.assertEqual(result['state'], 'complete')
self.assertEqual(self.state, 'stopped')
self.assertEqual(self.calls, ['status', 'start', 'exec', 'status', 'shutdown', 'status'])
capture.assert_called_once_with(100, 'run', refresh_indexes=True)
store.assert_called_once_with(100, self.result)
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_running_check_has_no_lifecycle_effect(self):
self.state = 'running'
result, _, _ = self.run_check()
self.assertEqual(result['state'], 'complete')
self.assertEqual(self.calls, ['status'])
self.assertEqual(self.state, 'running')
def test_index_read_timeout_and_persistence_errors_restore_stopped(self):
for error in [RuntimeError('index failed'), OSError('read failed'),
subprocess.TimeoutExpired('package listing', 30)]:
with self.subTest(error=type(error).__name__):
result, _, _ = self.run_check(capture_error=error)
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
result, _, _ = self.run_check(persistence_error=OSError('disk full'))
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
self.assertIn('could not be persisted', result['error'])
def test_start_timeout_after_effect_is_restored(self):
def transport(vmid, action, *args, **kwargs):
value = self.transport(vmid, action, *args, **kwargs)
if action == 'start':
raise subprocess.TimeoutExpired('start', 60)
return value
result, capture, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
capture.assert_not_called()
def test_unknown_status_does_not_start(self):
self.state = 'unknown'
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, ['status'])
capture.assert_not_called()
def test_shutdown_failure_uses_verified_stop(self):
def transport(vmid, action, *args, **kwargs):
if action == 'shutdown':
raise RuntimeError('shutdown failed')
return self.transport(vmid, action, *args, **kwargs)
result, _, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'complete')
self.assertEqual(self.state, 'stopped')
self.assertIn('stop', self.calls)
def test_failed_restore_revokes_installation(self):
def transport(vmid, action, *args, **kwargs):
if action in ('shutdown', 'stop'):
raise RuntimeError('stop failed')
return self.transport(vmid, action, *args, **kwargs)
result, _, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'failed')
self.assertIn('could not be restored', result['error'])
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_guard_oci_config_lock_and_disabled_forbid_all_guest_calls(self):
for config in ['lxc.environment.runtime: OCI=1\n', 'lock: backup\n']:
self.conf.write_text(config)
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, [])
capture.assert_not_called()
self.conf.unlink()
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, [])
capture.assert_not_called()
self.conf.write_text('hostname: fixture\n')
with patch.object(job.managed_installs, '_lxc_updates_detection_enabled', return_value=False):
result, capture, _ = self.run_check()
self.assertEqual(self.calls, [])
capture.assert_not_called()
def test_registered_oci_is_excluded_before_probe(self):
with patch.object(job.managed_installs, '_get_oci_managed_vmids', return_value={'100': 'fixture'}):
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, [])
capture.assert_not_called()
def test_unreadable_or_invalid_oci_registry_never_means_non_oci(self):
for content in ['broken', '{"instances": []}', '{"instances": {"app": {"vmid": 100}}}']:
job.OCI_REGISTRY.write_text(content)
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, [])
capture.assert_not_called()
def test_thread_start_failure_releases_lock_and_job(self):
with patch.object(job.threading, 'Thread') as thread:
thread.return_value.start.side_effect = RuntimeError('cannot create worker')
with self.assertRaises(RuntimeError):
job.start(100)
self.assertEqual(job._jobs, {})
self.assertEqual(job._attempts, {})
reservation = job.acquire(100)
reservation.close()
def test_corrupt_registry_is_preserved_and_installation_stays_blocked(self):
registry = self.folder / 'registry.json'
registry.write_text('malformed-but-must-not-be-overwritten')
with patch.object(job.managed_installs, '_REGISTRY_PATH', str(registry)):
result, _, _ = self.run_check(persistence_error=job.managed_installs.store_lxc_update_observation)
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
self.assertEqual(registry.read_text(), 'malformed-but-must-not-be-overwritten')
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_shared_lock_excludes_scheduler_and_manual_runner(self):
reservation = job.acquire(100)
self.addCleanup(reservation.close)
with self.assertRaises(BlockingIOError):
job.start(100)
process = subprocess.run(['/usr/bin/flock', '-n',
str(self.folder / 'proxmenux-lxc-update-100.lock'), '/bin/true'])
self.assertEqual(process.returncode, 1)
self.assertEqual(job._jobs, {})
reservation.close()
other = job.acquire(100)
other.close()
def test_cooldown_boundaries_and_inflight(self):
for elapsed, expected in [(59.999, 429), (60, 202), (60.001, 202)]:
job._jobs.clear()
job._attempts[100] = 100
reservation = Mock()
with patch.object(job.time, 'monotonic', return_value=100 + elapsed), \
patch.object(job.threading, 'Thread'), patch.object(job, 'acquire', return_value=reservation):
result, code = job.start(100)
self.assertEqual(code, expected)
job._jobs['active'] = dict(vmid=100, state='running', created=100)
job._attempts[100] = 0
with patch.object(job.time, 'monotonic', return_value=10000):
self.assertEqual(job.start(100)[1], 429)
def test_poll_is_read_only_and_scoped_to_guest(self):
job._jobs['run'] = dict(vmid=100, state='running', error=None, created=1)
with patch.object(job, 'command') as command, patch.object(job, 'guard') as guard:
self.assertEqual(job.get(100, 'run')['state'], 'running')
self.assertIsNone(job.get(101, 'run'))
self.assertIsNone(job.get(100, 'missing'))
command.assert_not_called()
guard.assert_not_called()
def test_install_gate_positive_zero_unknown_error_and_freshness(self):
now = datetime.datetime.now(datetime.timezone.utc)
for count, available, error, age, allowed in [
(2, True, None, 60, True), (0, False, None, 60, False),
(None, True, None, 60, False), (2, True, 'failed', 60, False),
(2, True, None, 172799, True), (2, True, None, 172800, False),
(2, True, None, -60, False)]:
with self.subTest(count=count, age=age, error=error):
item = {'id': 'lxc:100', 'update_check': {'_count': count,
'available': available, 'error': error,
'last_check': (now - datetime.timedelta(seconds=age)).isoformat()}}
with patch.object(job.managed_installs, 'get_active_items', return_value=[item]):
if allowed:
job.checked_os(100)
else:
with self.assertRaises((RuntimeError, ValueError)):
job.checked_os(100)
def test_pending_failure_marker_forbids_install_before_probe(self):
(self.folder / 'proxmenux-lxc-check-invalid-100').touch()
with self.assertRaises(RuntimeError):
job.checked_os(100)
def test_real_capture_refreshes_lists_and_persists_without_installation(self):
for count, index_rc in [(0, 0), (2, 0), (2, 1)]:
with self.subTest(count=count, index_rc=index_rc):
self.state = 'stopped'
reservation = job.acquire(100)
job._jobs['real'] = dict(vmid=100, state='running', created=time.monotonic())
entry = {'id': 'lxc:100', 'type': 'lxc', '_vmid': '100', '_os_family': 'debian'}
output = 'Listing...\n' + '\n'.join(
f'pkg{i}/stable 2.0 amd64 [upgradable from: 1.0]' for i in range(count))
commands = []
def transport(command, **kwargs):
commands.append(command)
return subprocess.CompletedProcess(command, index_rc if 'update' in command else 0,
output if 'list' in ' '.join(command) else '', '')
with patch.object(job, 'command', side_effect=self.transport), \
patch.object(job.managed_installs, '_DB_DIR', str(self.folder)), \
patch.object(job.managed_installs, '_REGISTRY_PATH', str(self.folder / 'registry.json')), \
patch.object(job.lxc_update_observation, 'OBSERVATION_DIR', self.folder / 'observations'), \
patch.object(job.managed_installs, '_detect_lxc_containers', return_value=[entry]), \
patch.object(job.managed_installs, '_probe_lxc_os', return_value='debian'), \
patch.object(job.managed_installs.subprocess, 'run', side_effect=transport):
job.run(100, 'real', reservation)
stored = job.managed_installs.get_registry()['items'][0]['update_check']
self.assertEqual(self.state, 'stopped')
self.assertEqual(job.get(100, 'real')['state'], 'failed' if index_rc else 'complete')
self.assertTrue(any('update' in command for command in commands))
self.assertFalse(any(any(word in command for word in ('upgrade', 'install', 'full-upgrade'))
for command in commands))
if not index_rc:
self.assertEqual(stored['_count'], count)
else:
self.assertTrue(stored['error'])
self.assertFalse(any('apt list' in ' '.join(command) for command in commands))
def test_actual_routes_submit_poll_and_control_lock(self):
app = Flask(__name__)
scope = {'app': app, 'jsonify': jsonify, 'require_auth': lambda fn: fn,
'_vm_cache_invalidate': Mock(), '_lxc_os_info_cache': {},
'_pvesh_cache': {}, '_get_lxc_update_status_map': lambda: {'100': {'count': 2}}}
names = {'api_lxc_updates_refresh_check', 'api_lxc_updates_refresh_check_status'}
nodes = [n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
if isinstance(n, ast.FunctionDef) and n.name in names]
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'routes', 'exec'), scope)
client = app.test_client()
with patch.object(job, 'start', return_value=({'job_id': 'run'}, 202)):
self.assertEqual(client.post('/api/vms/100/updates/refresh-check').status_code, 202)
job._jobs['run'] = dict(vmid=100, state='complete', error=None, created=1)
with patch.object(job, 'command') as command:
response = client.get('/api/vms/100/updates/refresh-check/run')
self.assertEqual(response.json['update_check']['count'], 2)
self.assertEqual(client.get('/api/vms/101/updates/refresh-check/run').status_code, 404)
command.assert_not_called()
@@ -67,3 +67,90 @@ for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
for (const key of ['dockerWorkloadsHeading', 'runsInsideDocker', 'upstreamDelegatedTitle', 'upstreamDelegatedHelp']) assert.equal(typeof messages.vmLxc.appEditor[key], 'string')
}
console.log('PASS Docker delegation: live cache, lifecycle invalidation, edit protection, no extra HTTP, unique image counts, eight locales')
// Exercise the actual bulk-choice construction, keeping target identities
// intact while distinguishing a custom updater from a same-named image.
const choicesSource = source.slice(source.indexOf('const bulkAppChoices ='), source.indexOf('const bulkChoiceIds ='))
assert(choicesSource.startsWith('const bulkAppChoices ='))
for (const locale of ['en', 'sk']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
const t = (key, values = {}) => {
const value = key.split('.').reduce((obj, part) => obj?.[part], messages)
assert.equal(typeof value, 'string', key)
return value.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? '')
}
const registeredApps = [
{id: 'custom', name: 'ghstats', update_command: 'existing updater'},
{id: 'helper', name: 'Helper app', update_method: 'helper', helper_slug: 'helper-app'},
{id: 'unique', name: 'Only custom', update_command: 'another updater'},
{id: 'engine', name: 'Docker', helper_slug: 'docker'},
].map(Object.freeze)
const units = [
{id: 'docker-unit:ghstats', kind: 'standalone', display_name: 'ghstats', dependent_services: []},
{id: 'docker-unit:helper', kind: 'compose', display_name: 'Helper app', dependent_services: ['database']},
{id: 'docker-unit:unique', kind: 'standalone', display_name: 'Only image', dependent_services: []},
].map(Object.freeze)
const before = JSON.stringify({registeredApps, units})
const result = compile(choicesSource + '\nexports.choices = bulkChoices;', {
exports: {}, t, registeredApps, helperExists: true, uc: {helper_slug: 'helper-app'},
dockerWorkloadsRegistered: true, dockerRegistered: true,
selectedVM: {docker_inventory: {update_units: units}}, followedImageReferences: new Set(),
}).choices
assert.deepEqual(Array.from(result, x => x.id), ['os', 'app:custom', 'app:helper', 'app:unique',
'docker-engine', 'docker-unit:ghstats', 'docker-unit:helper', 'docker-unit:unique'])
assert.equal(result.find(x => x.id === 'app:custom').label, `ghstats — ${t('vmLxc.updates.customCommandTitle')}`)
assert.equal(result.find(x => x.id === 'docker-unit:ghstats').label, `ghstats — ${t('vmLxc.bulkUpdate.dockerImageMethod')}`)
assert.equal(result.find(x => x.id === 'app:helper').label, `Helper app — ${t('vmLxc.updates.helperMethod')}`)
assert.equal(result.find(x => x.id === 'docker-unit:helper').label, 'Helper app — Docker Compose')
assert.equal(result.find(x => x.id === 'app:unique').label, 'Only custom')
assert.equal(result.find(x => x.id === 'docker-unit:unique').label, 'Only image')
assert.equal(result.find(x => x.id === 'docker-unit:helper').detail,
t('vmLxc.bulkUpdate.includesDependencies', {names: 'database'}))
assert.equal(JSON.stringify({registeredApps, units}), before, 'display must never edit registrations or units')
assert.equal(typeof messages.vmLxc.bulkUpdate.osDockerScope, 'string')
}
console.log('PASS bulk display: duplicate method names, unchanged unique names/target IDs/dependencies and frozen input, EN/SK')
const actionStyle = compile(fs.readFileSync(path.join(root, 'lib/update-action-style.ts'), 'utf8'), {exports: {}})
let bulkButton
function findBulkButton(node) {
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'Button') {
const props = node.openingElement.attributes.properties
if (props.some(p => ts.isJsxAttribute(p) && p.name.text === 'onClick'
&& p.initializer?.getText(tree).includes('applyBulkUpdate('))) bulkButton = props
}
ts.forEachChild(node, findBulkButton)
}
findBulkButton(tree)
assert(bulkButton, 'actual bulk execution button exists')
function bulkProp(name, context) {
const prop = bulkButton.find(p => p.name?.text === name)
assert(prop && ts.isJsxExpression(prop.initializer))
return compile('exports.value = (' + prop.initializer.expression.getText(tree) + ');', {
exports: {}, getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass, ...context,
}).value
}
const enabledBulk = {bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false,
osUpdateStatusKnown: true, bulkAllTargetsUpToDate: false, bulkHasPendingUpdates: true,
bulkTargets: ['os', 'app:one'], dockerInventoryRefreshing: false, dockerInventoryAvailable: true}
assert.equal(bulkProp('disabled', enabledBulk), false)
assert.match(bulkProp('className', enabledBulk), /text-purple-300/)
const unknownBulk = {...enabledBulk, osUpdateStatusKnown: false, bulkHasPendingUpdates: false}
assert.equal(bulkProp('disabled', unknownBulk), true, 'consistent action color must not enable an unknown OS')
assert.equal(bulkProp('className', unknownBulk), bulkProp('className', enabledBulk))
const currentBulk = {...enabledBulk, bulkAllTargetsUpToDate: true, bulkHasPendingUpdates: false}
assert.equal(bulkProp('disabled', currentBulk), true)
assert.match(bulkProp('className', currentBulk), /text-green-400/)
assert.equal(bulkProp('disabled', {...enabledBulk, bulkApplying: true}), true)
assert.equal(bulkProp('disabled', {...enabledBulk, bulkTargets: ['os', 'docker-unit:one'],
dockerInventoryAvailable: false}), true)
const helperLabelSource = source.slice(source.indexOf('const cls = getUpdateActionButtonClass(hasUpd'),
source.indexOf('const cls = getUpdateActionButtonClass(hasUpd') + 550)
const helperLabel = helperLabelSource.slice(0, helperLabelSource.indexOf('return ('))
const unknownHelper = compile(helperLabel + '\nexports.value = {cls, label};', {
exports: {}, hasUpd: false, upToD: false, t: k => k,
getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass,
}).value
assert.match(unknownHelper.cls, /text-purple-300/)
assert.equal(unknownHelper.label, 'vmLxc.updates.runUpdater', 'unknown version must not be presented as a detected upgrade')
console.log('PASS actual update actions: shared author colors, unknown remains unknown, bulk guards unchanged')
@@ -138,6 +138,7 @@ esac
notification = Mock()
ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification,
_LXC_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'),
_LXC_INSTALL_CHECKED_OS_SCRIPT=str(ROOT / 'scripts/lxc/install_checked_os_updates.sh'),
_normalise_lxc_update_run_id=lambda value, **_: value,
_normalise_lxc_update_targets=lambda values, _: values,
_normalise_lxc_update_labels=lambda values: values,
+6
View File
@@ -22,6 +22,7 @@ from flask import Flask, jsonify
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import managed_installs as managed
import lxc_check_job
SERVER = ROOT / "AppImage/scripts/flask_server.py"
FUNCTIONS = {
@@ -50,6 +51,9 @@ class ObservedStateRoutesTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
lock_patch = patch.object(lxc_check_job, 'LOCK_DIR', Path(self.temp.name))
lock_patch.start()
self.addCleanup(lock_patch.stop)
self.conf = Path(self.temp.name) / "100.conf"
self.conf.write_text("onboot: 0\nnet0: name=eth0,ip=dhcp\ntags: old\n")
self.clock = Mock(time=Mock(return_value=1000), monotonic=Mock(return_value=1000))
@@ -141,6 +145,8 @@ class ObservedStateRoutesTests(unittest.TestCase):
self.assertEqual(response.status_code, 200, response.json)
return response.json[0]
def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self):
first = self.row()
self.assertEqual(first["ip"], "10.100.100.222")
+336 -10
View File
@@ -19,6 +19,7 @@ const requests = []
const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), {
exports: {}, require: () => ({ fetchApi: async (...args) => { requests.push(args); return {} } }),
})
const actionStyle = compile(fs.readFileSync(path.join(root, "lib/update-action-style.ts"), "utf8"), { exports: {} })
assert.equal(requests.length, 0, "loading helpers never schedules a package check")
const now = Date.parse("2026-10-13T12:00:00Z")
const recent = { last_check: "2026-10-13T11:59:00Z" }
@@ -41,25 +42,51 @@ for (const lang of ['en', 'sk']) {
const source = fs.readFileSync(path.join(root, "components/virtual-machines.tsx"), "utf8")
const tree = ts.createSourceFile("vm.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
assert.equal(tree.parseDiagnostics.length, 0)
const wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge"])
const wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge", "openApplyTerminal", "handleVMControl"])
const definitions = []
const preferenceDefinitions = []
const status = []
let detailEffect
let resultEffect
let resultTimeout
let osApplyButton
let bulkApplyButton
let osSection
function walk(node) {
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "div"
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "className"
&& p.initializer?.getText(tree) === '"pb-4"')
&& node.getText(tree).includes('vmLxc.updates.osPackagesTitle')) osSection = node.getText(tree)
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "Button"
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "onClick"
&& p.initializer?.getText(tree).includes('openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })')))
osApplyButton = node.getText(tree)
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "Button"
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "onClick"
&& p.initializer?.getText(tree).includes('applyBulkUpdate(selectedVM.vmid)')))
bulkApplyButton = node.getText(tree)
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") {
detailEffect = node.arguments[0].getText(tree)
}
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[selectedVM, updatesRefreshing, updatesBaselineCount]")
resultEffect = node.arguments[0].getText(tree)
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[updatesRefreshing]")
resultTimeout = node.arguments[0].getText(tree)
if (ts.isVariableDeclaration(node)) {
const name = node.name.getText(tree)
if (wanted.has(name)) definitions.push(`const ${node.getText(tree)};`)
if (["applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
preferenceDefinitions.push(`const ${node.getText(tree)};`)
if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
status.push(`const ${node.getText(tree)};`)
}
ts.forEachChild(node, walk)
}
walk(tree)
assert.equal(definitions.length, 3)
assert.equal(definitions.length, 5)
assert.equal(status.length, 3)
assert.ok(detailEffect)
let checking = null
@@ -73,27 +100,61 @@ const context = {
Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron",
t: (key, args) => key + (args ? JSON.stringify(args) : ""),
isLxcUpdateStale: o => helpers.isLxcUpdateStale(o, now),
checkLxcUpdates: async id => {
getLxcOsUpdateState: o => helpers.getLxcOsUpdateState(o, now),
getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass,
refreshCheckLxcUpdates: async id => {
if (errorToThrow) throw errorToThrow
return helpers.checkLxcUpdates(id)
requests.push([`/api/vms/${id}/updates/refresh-check`, { method: "POST" }])
return { ...recent, available: true, count: 2 }
},
fetchApi: async endpoint => { requests.push([endpoint]); return { config: { onboot: 1 }, os_info: { id: "debian" } } },
mutate: async () => { polls++ },
selectedVMRef: { current: { vmid: 100 } },
osCheckRequestRef: { current: null },
selectedVM: { vmid: 100 },
setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 },
clearInterval: id => { context.clearedTimer = id },
vmModalCacheRef: { current: { details: new Map() } },
vmModalCacheRef: { current: { details: new Map(), backups: new Map(), mountPoints: new Map(), schedule: new Map(), firewall: new Map() } },
setVMDetails: value => { details = value },
setOsCheckingVmid: value => { checking = value },
setOsCheckError: value => { displayedError = value },
updatesRefreshing: true,
updatesBaselineCount: 20,
updatesBaselineCountRef: { current: 20 },
setUpdatesResult: value => { context.updateResult = value },
setUpdatesRefreshing: value => { context.refreshing = value },
setUpdatesBaselineCount: value => { context.baseline = value },
setTimeout: callback => { context.resultTimer = callback; return 2 },
clearTimeout: () => {},
Button: "Button", RefreshCw: "Refresh", confirm: () => context.confirmed,
window: { crypto: { randomUUID: () => "ui-test-run" } },
applyBackupStorage: "", selectedBackupStorage: "", backupStorages: [],
osCheckingVmid: null, pendingBtnCls: "pending", upToDateBtnCls: "current", neutralBtnCls: "unknown",
bulkApplying: false, bulkTargets: ["os", "app:test"], bulkHasPendingUpdates: true,
bulkAllTargetsUpToDate: false, dockerInventoryRefreshing: false, dockerInventoryAvailable: true,
controlLoading: false, Package: "Package", Shield: "Shield", CheckCircle2: "Current", AlertTriangle: "Unknown",
osCheckError: null, uc: undefined, osUpdateStale: true,
setControlLoading: value => { context.controlLoading = value },
invalidateLxcApps: () => {}, setSelectedVM: value => { context.selectionChanged = value },
alert: value => { context.alerted = value },
}
for (const name of ["Vmid", "Target", "RunId", "TargetIds", "TargetLabels", "UpdateCommand", "AppName",
"RunHelper", "AllowHelperWithCustom", "DockerStandaloneTargets", "DockerEngine", "DockerRefresh",
"BackupStorage", "StartedAt", "Open", "CheckAndUpdate", "CheckedOs"])
context[`setApply${name}`] = value => { context[`apply${name}`] = value }
const ui = compile(definitions.join("\n") + `
exports.check = checkOsUpdates;
exports.aggregate = getAggregateUpdateCheck;
exports.badge = renderLxcUpdateBadge;
exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; };
exports.revalidateDetails = ${detailEffect};
exports.settleResult = ${resultEffect};
exports.resultTimeout = ${resultTimeout};
exports.openApply = openApplyTerminal;
exports.osApplyButton = () => (${osApplyButton});
exports.bulkApplyButton = () => (${bulkApplyButton});
exports.osSection = () => (${osSection});
exports.control = handleVMControl;
`, context)
const legacy = { last_check: "2026-10-12T00:00:00Z", available: true, count: 51, stale: true }
assert.equal(ui.osStatus(legacy).osUpdateStatusKnown, false, "old count must not claim live OS status")
@@ -106,22 +167,285 @@ assert.equal(ui.badge(legacy).props.children[1], "vmLxc.updates.checkStale")
assert.equal(ui.badge({ ...recent, available: true, count: 2 }).props.children[1], 2)
assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null)
assert.equal(requests.length, 0)
context.updatesRefreshing = false
for (const [observation, expected] of [
[undefined, "unknown"], [legacy, "unknown"], [{ ...recent, error: "failed" }, "unknown"],
[{ ...recent, available: false }, "unknown"],
[{ ...recent, available: true, count: 0 }, "unknown"],
[{ ...recent, available: false, count: 0 }, "current"],
[{ ...recent, available: true, count: 3 }, "pending"],
]) {
assert.equal(helpers.getLxcOsUpdateState(observation, now), expected)
context.selectedVMRef.current = { vmid: 100, status: "stopped", update_check: observation }
context.selectedVM = context.selectedVMRef.current
Object.assign(context, ui.osStatus(observation))
context.applyOpen = false
context.confirmed = true
assert.equal(ui.osApplyButton().props.disabled, expected !== "pending")
assert.equal(ui.bulkApplyButton().props.disabled, expected === "unknown")
ui.openApply(100, "os")
assert.equal(context.applyOpen, expected === "pending", "handler must enforce the same boundary as the button")
}
context.confirmed = false
context.applyOpen = false
ui.openApply(100, "os")
assert.equal(context.applyOpen, true, "an explicit installation click must not ask for a second stopped-guest confirmation")
const oldConfirm = context.confirm
let confirmationCalls = 0
context.confirm = () => { confirmationCalls++; return false }
for (const [target, options] of [
['os', { checkedOs: true }],
['both', { updateCommand: 'registered-app-update', targetIds: ['os', 'app:test'] }],
['app', { updateCommand: 'registered-app-update' }],
['app', { dockerEngine: true }],
['app', { dockerStandaloneTargets: ['registered-container'] }],
]) {
context.applyOpen = false
ui.openApply(100, target, options)
assert.equal(context.applyOpen, true, `explicit ${target} update opens the terminal directly`)
}
assert.equal(confirmationCalls, 0, 'ordinary update entry points never open a browser confirmation')
assert(!source.includes('checkAndUpdate'), 'the abandoned combined check/install action has no UI path')
assert(!fs.existsSync(path.join(root, '../scripts/lxc/check_apply_updates.sh')))
context.confirm = oldConfirm
context.confirmed = true
context.applyOpen = false
context.osCheckingVmid = 100
ui.openApply(100, "os")
assert.equal(context.applyOpen, false, "checking cannot race an update launch")
context.osCheckingVmid = null
context.bulkAllTargetsUpToDate = true
assert.equal(ui.bulkApplyButton().props.disabled, true)
context.bulkAllTargetsUpToDate = false
context.confirmed = true
context.selectedVMRef.current = { vmid: 100, status: "stopped", update_check: { ...recent, available: false, count: 0 } }
ui.openApply(100, "both", { updateCommand: "app-updater", targetIds: ["os", "app:test"] })
assert.equal(context.applyOpen, true, "combined app update remains supported when OS packages are current")
assert.equal(context.applyUpdateCommand, "app-updater")
context.selectedVMRef.current = { vmid: 100, status: "running", update_check: { ...recent, error: "failed" } }
context.applyOpen = false
ui.openApply(100, "both")
assert.equal(context.applyOpen, false, "a combined plan cannot bypass unknown OS state")
ui.openApply(100, "app", { updateCommand: "app-updater" })
assert.equal(context.applyOpen, true, "app-only updates do not depend on OS package observation")
function collectButtons(node) {
if (!node || typeof node !== "object") return []
if (Array.isArray(node)) return node.flatMap(collectButtons)
return [...(node.type === "Button" ? [node] : []), ...collectButtons(node.props?.children)]
}
function collectText(node) {
if (typeof node === "string") return node
if (!node || typeof node !== "object") return ""
if (Array.isArray(node)) return node.map(collectText).join(" ")
return collectText(node.props?.children)
}
for (const observation of [undefined, legacy, { ...recent, error: "check in progress" },
{ ...recent, available: false, count: 0 }, { ...recent, available: true, count: 3 }]) {
context.selectedVM = { vmid: 100, status: "running" }
context.uc = observation
context.osUpdateStale = helpers.isLxcUpdateStale(observation, now)
Object.assign(context, ui.osStatus(observation))
context.osCheckingVmid = 100
const rendered = ui.osSection()
assert.match(collectText(rendered), /vmLxc\.updates\.checkInProgress/,
"an active check takes precedence over old, missing and invalidated observations")
assert.doesNotMatch(collectText(rendered), /osStatusUnavailable|osUpToDate|noPending/)
assert.equal(collectButtons(rendered).length, 1, "hide the previous result during a check")
context.osCheckingVmid = 211
assert.doesNotMatch(collectText(ui.osSection()), /checkInProgress/,
"another container's check cannot replace this container's result")
}
context.osCheckingVmid = null
context.uc = { ...recent, error: "check failed" }
Object.assign(context, ui.osStatus(context.uc))
assert.match(collectText(ui.osSection()), /osStatusUnavailable/, "completed failures remain visible")
for (const status of ["running", "stopped"]) {
for (const observation of [undefined, legacy, { ...recent, error: 'read failed' },
{ ...recent, available: true, count: 0 },
{ ...recent, available: false, count: 0 }, { ...recent, available: true, count: 3 }]) {
context.selectedVM = { vmid: 100, status, update_check: observation }
context.selectedVMRef.current = context.selectedVM
context.uc = observation
context.osUpdateStale = helpers.isLxcUpdateStale(observation, now)
Object.assign(context, ui.osStatus(observation))
const buttons = collectButtons(ui.osSection())
assert.equal(buttons.length, context.osUpdateStatusKnown ? 2 : 1,
'retain the original bottom-right OS status when a verified zero is available')
assert.equal(buttons[0].props.children[1], "vmLxc.updates.checkSystemPackages")
if (context.osUpdateStatusKnown && !context.hasOsUpdates) {
const currentStatus = buttons[1]
assert.equal(currentStatus.props.asChild, true, 'current is a status, not an installation action')
assert.equal(currentStatus.props.onClick, undefined)
assert.equal(currentStatus.props.className, context.upToDateBtnCls)
assert.equal(currentStatus.props.children.type, 'span')
assert.equal(currentStatus.props.children.props.role, 'status')
assert.equal(currentStatus.props.children.props.children, 'vmLxc.updates.osUpToDate')
}
assert.equal((osSection.match(/vmLxc\.updates\.refreshCheckHelp/g) || []).length, 1,
'keep the explanation in the check tooltip without duplicating it as an inline paragraph')
if (status === "stopped") {
context.applyOpen = false
context.confirmed = false
ui.openApply(100, "os", { checkedOs: true })
assert.equal(context.applyOpen, context.hasOsUpdates, "explicit OS installation has no second popup")
if (context.hasOsUpdates) assert.equal(context.applyCheckedOs, true)
}
}
}
context.selectedVMRef.current = { vmid: 100, status: "running" }
context.updatesRefreshing = true
context.selectedVM = { update_check: { count: 0, error: "verification failed" } }
ui.settleResult()
assert.equal(context.updateResult, null, "unknown must not produce an all-updated banner")
context.selectedVMRef.current = context.selectedVM
ui.resultTimeout()
context.resultTimer()
assert.equal(context.updateResult, null, "timeout must not turn an unknown observation into success")
context.selectedVM = { update_check: { count: 0, error: null } }
ui.settleResult()
assert.equal(context.updateResult.pendingAfter, 0)
assert.equal(context.updateResult.appliedCount, 20)
context.selectedVM = { vmid: 100 }
context.selectedVMRef.current = { vmid: 100 }
;(async () => {
// Execute the actual loaders with delayed GETs to test guest isolation.
const preferenceState = {}
const pendingReads = []
const preferenceContext = {
exports: {}, CRON_PRESETS: [{ value: "daily-3am", cron: "0 3 * * *" }],
selectedVMRef: { current: { vmid: 213 } },
scheduleLoadRequestRef: { current: 0 }, bulkLoadRequestRef: { current: 0 },
vmModalCacheRef: { current: { schedule: new Map() } },
t: key => key,
fetchApi: (url, options) => new Promise((resolve, reject) => {
assert.equal(options, undefined, "loading preferences never writes or starts an update")
pendingReads.push({ url, resolve, reject })
}),
}
for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset",
"ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunLog",
"ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError",
"ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"])
preferenceContext[`set${name}`] = value => { preferenceState[name] = value }
assert.equal(preferenceDefinitions.length, 3)
const preferences = compile(preferenceDefinitions.join("\n") + `
exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate;
`, preferenceContext)
preferences.apply({ backup: true, backup_storage: "local", restart: true })
preferences.apply({})
assert.equal(preferenceState.ApplyBackup, false, "a guest without saved options cannot inherit backup")
assert.equal(preferenceState.ApplyBackupStorage, "", "missing storage clears another guest's choice")
assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot")
preferences.apply({ backup: false, backup_storage: "", restart: false })
assert.equal(preferenceState.ApplyBackupStorage, "")
preferenceContext.vmModalCacheRef.current.schedule.set(213, { backup: true, backup_storage: "local", restart: true })
const previousSchedule = preferences.schedule(213)
assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache")
preferenceContext.selectedVMRef.current = { vmid: 210 }
const currentSchedule = preferences.schedule(210)
assert.equal(preferenceState.ApplyBackup, false, "clear inherited options before the GET completes")
assert.equal(preferenceState.ApplyRestart, false)
pendingReads[1].resolve({})
await currentSchedule
pendingReads[0].resolve({ backup: true, backup_storage: "wrong", restart: true })
await previousSchedule
assert.equal(preferenceState.ApplyBackup, false)
assert.equal(preferenceState.ScheduleLoaded, 210, "old completion cannot mark another guest loaded")
const older = preferences.schedule(210)
const newer = preferences.schedule(210)
pendingReads[3].resolve({ backup: true, backup_storage: "new", restart: false })
await newer
pendingReads[2].resolve({ backup: false, backup_storage: "old", restart: true })
await older
assert.equal(preferenceState.ApplyBackupStorage, "new", "only the latest request may publish preferences")
assert.equal(preferenceState.ApplyRestart, false)
const closed = preferences.schedule(210)
const closedState = JSON.stringify(preferenceState)
preferenceContext.selectedVMRef.current = null
pendingReads[4].reject(new Error("old guest failure"))
await closed
assert.equal(JSON.stringify(preferenceState), closedState, "closing the modal forbids late error and loaded writes")
preferenceContext.selectedVMRef.current = { vmid: 213 }
const oldBulk = preferences.bulk(213)
preferenceContext.selectedVMRef.current = { vmid: 210 }
const newBulk = preferences.bulk(210)
pendingReads[6].resolve({ targets: ["os", "app:ghstats"] })
await newBulk
pendingReads[5].resolve({ targets: ["os", "docker-engine"] })
await oldBulk
assert.equal(JSON.stringify(preferenceState.BulkTargets), '["os","app:ghstats"]')
assert.equal(preferenceState.BulkLoaded, 210)
const oldBulkSameGuest = preferences.bulk(210)
const newBulkSameGuest = preferences.bulk(210)
pendingReads[8].resolve({ targets: ["os", "docker-engine"] })
await newBulkSameGuest
pendingReads[7].reject(new Error("superseded bulk failure"))
await oldBulkSameGuest
assert.equal(preferenceState.BulkError, null)
assert.equal(JSON.stringify(preferenceState.BulkTargets), '["os","docker-engine"]')
const failedSchedule = preferences.schedule(210)
pendingReads[9].resolve(null)
await failedSchedule
assert.equal(preferenceState.ScheduleError, "vmLxc.scheduled.loadFailed", "invalid responses are failed reads")
const failedBulk = preferences.bulk(210)
pendingReads[10].resolve([])
await failedBulk
assert.equal(preferenceState.BulkError, "vmLxc.bulkUpdate.loadFailed")
const closedBulk = preferences.bulk(210)
const closedBulkState = JSON.stringify(preferenceState)
preferenceContext.selectedVMRef.current = null
pendingReads[11].resolve({ targets: ["os", "app:wrong-guest"] })
await closedBulk
assert.equal(JSON.stringify(preferenceState), closedBulkState)
const readsBeforeClosedLoad = pendingReads.length
await preferences.schedule(210)
await preferences.bulk(210)
assert.equal(pendingReads.length, readsBeforeClosedLoad, "an unselected guest cannot start a preferences GET")
assert(pendingReads.every(read => /\/(schedule|bulk-update)$/.test(read.url)), "no guest or updater routes")
// Exercise the real separate helper: POST submits, GET waits, no apply.
const io = []
let jobState = 'running'
const jobHelpers = compile(fs.readFileSync(path.join(root, 'lib/lxc-update-observation.ts'), 'utf8'), {
exports: {}, Date, setTimeout: callback => { jobState = 'complete'; callback() },
require: () => ({ fetchApi: async (url, opts) => {
io.push([url, opts])
if (opts?.method === 'POST') return { job_id: 'check-only' }
return { state: jobState, update_check: { count: 0, available: false }, error: 'index failed' }
} }),
})
assert.equal((await jobHelpers.refreshCheckLxcUpdates(100)).count, 0)
assert.equal(io[0][0], '/api/vms/100/updates/refresh-check')
assert.equal(io.filter(entry => entry[1]?.method === 'POST').length, 1)
assert(io.slice(1).every(entry => entry[0].endsWith('/refresh-check/check-only')))
jobState = 'failed'
await assert.rejects(jobHelpers.refreshCheckLxcUpdates(100), /osStatusUnavailable/)
io.length = 0
await jobHelpers.checkLxcUpdates(100)
assert.equal(io[0][0], '/api/vms/100/updates/check', 'legacy helper retains read-only endpoint')
context.applyOpen = false
context.updatesRefreshing = false
await ui.check(100)
assert.equal(requests[0][0], "/api/vms/100/updates/check")
assert.equal(requests[0][0], "/api/vms/100/updates/refresh-check")
assert.equal(requests[0][1].method, "POST")
assert.equal(requests[1][0], "/api/vms/100")
assert.equal(polls, 1)
assert.equal(polls, 2)
assert.equal(details.os_info.id, "debian")
assert.equal(checking, null)
errorToThrow = new Error('raw transport error must not appear in translated UI')
await ui.check(100)
assert.equal(displayedError, 'vmLxc.updates.osStatusUnavailable')
errorToThrow = new Error('checkStillRunning')
await ui.check(100)
assert.equal(displayedError, 'vmLxc.updates.checkStillRunning')
const before = requests.length
errorToThrow = { status: 429, body: { retry_after: 60 } }
await ui.check(100)
assert.match(displayedError, /checkCooldown.*60/)
assert.equal(checking, null)
assert.equal(requests.length, before)
assert.equal(polls, 1)
assert.equal(polls, 2)
// Switching guests while a request runs must not replace another modal.
errorToThrow = null
context.selectedVMRef.current = { vmid: 200 }
@@ -143,9 +467,11 @@ assert.equal(requests.length, 0)
assert.equal(context.clearedTimer, 1)
for (const locale of ["en", "sk"]) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))).vmLxc.updates
for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp"])
for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp",
"checkBeforeUpdate", "startBeforeCheck", "notCheckedYet",
"checkStillRunning"])
assert.ok(messages[key]?.trim())
assert.match(messages.checkCooldown, /\{seconds\}/)
}
console.log("PASS observed-state UI: freshness, badges, safe button, cooldown, modal isolation, EN/SK")
console.log("PASS observed-state UI: freshness, safe actions, per-guest preferences/bulk races, EN/SK")
})().catch(error => { console.error(error); process.exitCode = 1 })
+129
View File
@@ -0,0 +1,129 @@
// Execute the real Updates JSX and handlers with independent state fixtures.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const root = path.resolve(__dirname, '../../AppImage')
const ts = require(path.join(root, 'node_modules/typescript'))
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let appRender, bulkRender, checkButton, checkHandler
const declarations = []
function walk(node) {
if (ts.isCallExpression(node) && node.expression.getText(tree) === 'appSections.map') appRender = node.arguments[0].getText(tree)
if (ts.isConditionalExpression(node) && node.condition.getText(tree) === 'bulkConfigured'
&& node.whenTrue.getText(tree).includes('applyBulkUpdate(')) bulkRender = node.whenTrue.getText(tree)
if (ts.isVariableDeclaration(node)) {
if (node.name.getText(tree) === 'checkOsUpdates') checkHandler = node.getText(tree)
if (['selectedBulkUpdateStates', 'bulkHasPendingUpdates', 'bulkAllTargetsUpToDate', 'bulkHasUnknownTargets'].includes(node.name.getText(tree)))
declarations.push('const ' + node.getText(tree) + ';')
}
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'Button'
&& node.openingElement.attributes.getText(tree).includes('checkOsUpdates(selectedVM.vmid)')) checkButton = node.getText(tree)
ts.forEachChild(node, walk)
}
walk(tree)
assert(appRender && bulkRender && checkButton && checkHandler)
function compile(code, context) {
const result = ts.transpileModule(code, {compilerOptions: {target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX}, reportDiagnostics: true})
assert.equal(result.diagnostics.length, 0)
vm.runInNewContext(result.outputText, context)
return context.exports
}
function flatten(node) {
if (Array.isArray(node)) return node.flatMap(flatten)
return node && typeof node === 'object' ? [node, ...flatten(node.props?.children)] : []
}
function text(node) {
if (Array.isArray(node)) return node.map(text).join(' ')
return node && typeof node === 'object' ? text(node.props?.children) : typeof node === 'string' ? node : ''
}
for (const locale of ['en', 'sk']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
const t = key => key.split('.').reduce((o, part) => o?.[part], messages) ?? key
const calls = []
const context = {exports: {}, t, customCmdEditingApp: null, uc: null, helperExists: false,
helperUsesWebUpdater: false, selectedVM: {vmid: 210}, openCustomCmdEditor() {},
openApplyTerminal: (...args) => calls.push(args), getUpdateActionButtonClass: () => 'original-style',
bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false, controlLoading: false,
applyOpen: false, osUpdateStatusKnown: true, hasOsUpdates: false,
dockerEngineHasUpdate: false, dockerEngineUpToDate: true, dockerInventoryRefreshing: false,
dockerInventoryAvailable: true, dockerUpdateUnits: [], selectedBulkLabels: ['OS', 'Custom app'],
bulkTargets: ['os', 'app:custom'], applyBulkUpdate: () => calls.push('bulk'), checkOsUpdates() {},
require: () => ({jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})})}
for (const name of ['Button', 'Package', 'Settings2', 'ArrowUpCircle', 'CheckCircle2', 'RefreshCw', 'Pin', 'Loader2']) context[name] = name
const ui = compile('exports.app = (' + appRender + '); exports.check = () => (' + checkButton + ');', context)
const original = {id: 'custom', name: 'Custom app', installed_via: 'manual', installed_version: null,
latest_version: '2.0', update_available: null, update_command: 'approved-command', checked_at: '2026-10-14T10:43:19Z'}
const before = JSON.stringify(original)
const rendered = ui.app(original)
assert.match(text(rendered), locale === 'sk' ? /Nainštalovaná verzia nie je overená/ : /Installed version is unverified/)
assert.doesNotMatch(text(rendered), /Čaká sa|pending|upstreamAvailable/)
const action = flatten(rendered).find(n => n.type === 'Button')
assert.equal(text(action).trim(), locale === 'sk' ? 'Spustiť vlastný príkaz' : 'Run custom command')
assert.equal(calls.length, 0, 'render/check timestamp must not execute an updater')
action.props.onClick()
assert.equal(calls.length, 1)
assert.equal(calls[0][0], 210)
assert.equal(calls[0][1], 'app')
assert.equal(calls[0][2].updateCommand, 'approved-command')
assert.deepEqual(Array.from(calls[0][2].targetIds), ['app:custom'])
assert.equal(JSON.stringify(original), before, 'UI must not confirm or edit the stored version')
calls.length = 0
const knownVersion = ui.app({...original, installed_version: '1.0'})
assert(text(knownVersion).includes('1.0'), 'keep the known installed version when comparison is unavailable')
assert.doesNotMatch(text(knownVersion), /Čaká sa|Version tracking pending/)
const noDetector = ui.app({...original, installed_via: undefined})
assert(text(noDetector).includes(t('vmLxc.updates.versionTrackingNotConfigured')))
assert.equal(text(flatten(noDetector).find(n => n.type === 'Button')).trim(), t('vmLxc.updates.runCustomCommand'))
assert.equal(text(ui.check()).trim(), locale === 'sk' ? 'Skontrolovať balíky systému' : 'Check system packages')
for (const installed_via of ['binary', 'file', 'dpkg', 'apk']) {
const unavailable = ui.app({...original, installed_via, error: 'probe failed'})
assert.match(text(unavailable), locale === 'sk' ? /Dostupnosť aktualizácie nie je overená/ : /Update availability is unverified/)
assert.doesNotMatch(text(unavailable), /Čaká sa|next scheduled|upToDateAtLabel/)
}
const current = {...original, installed_version: '2.0', update_available: false}
const pending = {...original, installed_version: '1.0', update_available: true}
assert(text(ui.app(current)).includes(t('vmLxc.updates.upToDateAtLabel')))
assert(text(ui.app(pending)).includes(t('vmLxc.updates.installedLabel')))
assert.equal(text(flatten(ui.app(pending)).find(n => n.type === 'Button')).trim(), t('vmLxc.updates.applyUpdate'))
assert(!flatten(ui.app({...original, update_command: ''})).some(n => n.type === 'Button'), 'no command means no execution')
function bulk(app, osKnown = true, extra = {}) {
return compile(declarations.join('\n') + '\nexports.rendered=(' + bulkRender + ');',
{...context, exports: {}, registeredApps: [app], osUpdateStatusKnown: osKnown, ...extra}).rendered
}
const unknownBulk = bulk(original)
assert.match(text(unknownBulk), locale === 'sk' ? /nie je overená/ : /is unverified/)
assert.equal(flatten(unknownBulk).find(n => n.type === 'Button').props.disabled, false, 'preserve explicit manual execution')
assert.equal(flatten(bulk(current)).find(n => n.type === 'Button').props.disabled, true)
assert.equal(flatten(bulk(original, false)).find(n => n.type === 'Button').props.disabled, true, 'unknown OS blocks bulk')
assert.equal(flatten(bulk(original, true, {bulkApplying: true})).find(n => n.type === 'Button').props.disabled, true)
const mixed = bulk(pending, true, {registeredApps: [pending, {...original, id: 'unknown'}],
bulkTargets: ['os', 'app:custom', 'app:unknown']})
assert.match(text(mixed), locale === 'sk' ? /nie je overená/ : /is unverified/)
assert.equal(flatten(mixed).find(n => n.type === 'Button').props.disabled, false)
const imageUnavailable = bulk(original, true, {bulkTargets: ['os', 'docker-unit:one'], dockerInventoryAvailable: false})
assert.equal(flatten(imageUnavailable).find(n => n.type === 'Button').props.disabled, true, 'unavailable image cannot be run')
assert.doesNotMatch(text(bulk(pending)), locale === 'sk' ? /nie je overená/ : /is unverified/)
assert.equal(calls.length, 0, 'all status fixtures are side-effect free')
}
;(async () => {
const context = {exports: {}, osCheckRequestRef: {current: null}, applyOpen: false, controlLoading: false,
selectedVMRef: {current: {vmid: 210}}, vmModalCacheRef: {current: {details: new Map()}},
setOsCheckingVmid() {}, setOsCheckError() {}, setSelectedVM() {}, setVMDetails() {}, mutate: async () => {},
t: key => key, requests: []}
context.refreshCheckLxcUpdates = async vmid => {context.requests.push(['check', vmid]); return {count: 0}}
context.fetchApi = async (url, options) => {context.requests.push([url, options]); return {}}
const handler = compile('const ' + checkHandler + '; exports.check = checkOsUpdates;', context)
await handler.check(210)
assert.deepEqual(context.requests, [['check', 210], ['/api/vms/210', undefined]])
context.requests.length = 0
for (const guard of ['applyOpen', 'controlLoading']) {
context[guard] = true
await handler.check(210)
context[guard] = false
}
assert.equal(context.requests.length, 0, 'blocked checks make no API/probe requests')
console.log('PASS common update status: EN/SK unknown/current/pending/error/custom/no-command/bulk/OS check guards; actual JSX and handlers')
})().catch(error => {console.error(error); process.exitCode = 1})