Files
ProxMenux/tests/lxc_updates/test_native_app_discovery_ui.cjs

295 lines
16 KiB
JavaScript

// Execute the real panel, handlers and editor with inert hook/UI/API fixtures.
// This is component interaction evidence, not browser or Proxmox acceptance.
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/lxc-app-panel.tsx'), 'utf8')
const compiled = ts.transpileModule(source, {compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
}, reportDiagnostics: true})
assert.equal(compiled.diagnostics.length, 0)
const flatten = node => !node || typeof node !== 'object' ? [] : [node, ...(
[node.props?.children].flat(Infinity).flatMap(flatten)
)]
const text = node => node == null || node === false ? '' : typeof node !== 'object'
? String(node) : [node.props?.children].flat(Infinity).map(text).join(' ')
const candidate = {
slug: 'native-' + 'a'.repeat(24), discovery_id: 'native-' + 'a'.repeat(24),
name: 'handmade', source: 'native_metadata', installed_version: null,
evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [],
}
function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = [], translate,
confirmAccepted = false, apiError = null, detectorResult = null} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [], memoStates = []
const calls = [], cachedWrites = [], confirmPrompts = []
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
let dockerStatus = "complete", dockerCached = {vmid:101,status:"not_scanned",candidates:[],stale:false}
let holdDocker, dockerGate
let cacheGate, resolveCache, cachePending = false
let iconGate, resolveIcon, iconResult = {logo_url: '/api/vms/101/apps/logo-cache/' + 'b'.repeat(32)}, iconError
const seed = {sidecar: {vmid: 101, apps}, suggestions: {extras: [], port_suggestions: []}}
let bundleReads = 0
const react = {
useState(initial) {
const index = cursor++
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}]
},
useRef(value) {const index = cursor++; return refs[index] ||= {current: value}},
useMemo(fn, deps) {
const index = cursor++, prior = memoStates[index]
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) memoStates[index] = {deps, value: fn()}
return memoStates[index].value
},
useCallback(fn, deps) {return react.useMemo(() => fn, deps)},
useEffect(fn, deps) {
const index = cursor++, prior = effectStates[index]
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) {
effects.push(() => {prior?.cleanup?.(); effectStates[index] = {deps, cleanup: fn()}})
}
},
}
const api = async (url, options = {}) => {
calls.push({url, ...options})
if (apiError && options.method && options.method !== 'GET') throw new Error(apiError)
if (detectorResult && url.endsWith('/apps/test')) return detectorResult
if (url.endsWith('/logo-discovery')) {
if (iconGate) await iconGate
if (iconError) throw iconError
return iconResult
}
if (url.endsWith('/native-discovery')) {
if (options.method !== 'POST' && cachePending) {
cachePending = false
const oldSnapshot = structuredClone(native)
await cacheGate
return oldSnapshot
}
if (options.method === 'POST') {
if (scanGate) await scanGate
native = {...native, status: scanStatus, issues: scanIssues, candidates: nativeCandidate ? [nativeCandidate] : []}
}
return structuredClone(native)
}
if (url.endsWith('/docker-discovery')) {
if (options.method === 'POST') {
if (dockerGate) await dockerGate
dockerCached = {vmid:Number(url.split('/')[3]),status:dockerStatus,
stale:dockerStatus !== 'complete',candidates:dockerCandidate ? [dockerCandidate] : []}
}
return structuredClone(dockerCached)
}
if (url.endsWith('/apps/suggestions')) return seed.suggestions
if (url.endsWith('/apps') && options.method === 'POST') {
if (saveGate) await saveGate
return {vmid: Number(url.split('/')[3]), apps: [{id: 'saved', ...JSON.parse(options.body), state: {}}]}
}
if (url.endsWith('/adguard-setup')) return {available: false}
return []
}
const cache = {
getLxcAppsCached: () => null, fetchLxcApps: async () => {bundleReads++; return seed},
subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args),
}
const exports = {}
vm.runInNewContext(compiled.outputText, {exports, console, setTimeout, clearTimeout, AbortController,
confirm: prompt => {confirmPrompts.push(prompt); return confirmAccepted},
require: name => {
if (name === 'react') return react
if (name === 'react/jsx-runtime') return {jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})}
if (name.includes('api-config')) return {fetchApi: api}
if (name.includes('lxc-app-editor-errors')) {
const context = {exports: {}}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'lib/lxc-app-editor-errors.ts'), 'utf8'), {
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020},
}).outputText, context)
return context.exports
}
if (name.includes('lxc-apps-cache')) return cache
if (name.includes('i18n/provider')) return {useT: () => translate || ((key, values) => key + (values ? JSON.stringify(values) : ''))}
if (name.includes('category-color')) return {useIsLightTheme: () => false, categoryChipStyle: () => ({})}
if (name.includes('category-label')) return {getCategoryLabel: value => value}
return new Proxy({}, {get: (_, key) => key})
},
})
function render(next = props || {vmid: 101, initialData: seed}) {
props = next; cursor = 0; effects = []
tree = exports.LxcAppPanel(props)
for (const effect of effects) effect()
return tree
}
// Exercise React's key identity boundary using the actual parent expression.
const parent = ts.createSourceFile('parent.tsx', fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let keyExpression
function visit(node) {
if (ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent) === 'LxcAppPanel') {
keyExpression = node.attributes.properties.find(p => p.name?.getText(parent) === 'key').initializer.expression.getText(parent)
}
ts.forEachChild(node, visit)
}
visit(parent)
const parentKey = new Function('selectedVM', 'return ' + keyExpression)
let lastKey
function renderParent(vmid = 101, revision = 0) {
const key = parentKey({vmid, modal_cache_revision: revision})
if (lastKey !== undefined && lastKey !== key) {
effectStates.forEach(effect => effect?.cleanup?.())
states = []; refs = []; effectStates = []; memoStates = []
}
lastKey = key
return render({vmid, cacheRevision: revision, initialData: seed})
}
const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()}
const button = key => {
const label = translate ? translate('vmLxc.appEditor.' + key) : 'vmLxc.appEditor.' + key
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes(label))
assert.ok(match, 'Missing button ' + key)
return match
}
return {render, renderParent, settle, calls, cachedWrites, confirmPrompts, button, tree: () => tree, bundleReads: () => bundleReads,
holdIcon: () => {iconGate = new Promise(resolve => {resolveIcon = resolve})},
resolveIcon: () => resolveIcon(), setIconResult: value => {iconResult = value},
setIconError: value => {iconError = value},
setNative: value => {native = value},
holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})},
resolveInitialCache: () => resolveCache(),
setScanStatus: value => {scanStatus = value},
setDockerStatus: value => {dockerStatus = value},
holdDocker: () => {dockerGate = new Promise(resolve => {holdDocker = resolve})}, resolveDocker: () => holdDocker(),
setScanIssues: value => {scanIssues = value},
holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(),
holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(),
}
}
async function main() {
const f = fixture()
f.render(); await f.settle()
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0, 'mount and cache reads never scan')
await f.button('searchApplications').props.onClick(); f.render()
assert.equal(f.calls.filter(c => c.url.endsWith('/native-discovery') && c.method === 'POST').length, 1)
assert.ok(text(f.tree()).includes('nativeVersionUnknown'), 'unknown version remains visible')
assert.ok(text(f.tree()).includes('/opt/handmade/server'))
await f.button('registerButton').props.onClick(); f.render()
assert.ok(text(f.tree()).includes('saveButton'), 'candidate opens actual editor')
f.button('cancelButton').props.onClick(); f.render()
assert.equal(f.calls.filter(c => /\/apps$/.test(c.url) && c.method === 'POST').length, 0, 'cancel never registers')
await f.button('registerButton').props.onClick(); f.render()
await f.button('saveButton').props.onClick(); f.render()
const saved = f.calls.find(c => /\/apps$/.test(c.url) && c.method === 'POST')
const payload = JSON.parse(saved.body)
assert.equal(saved.url, '/api/vms/101/apps')
assert.equal(payload.discovery_id, candidate.discovery_id)
assert.ok(!payload.helper_slug, 'native identity never becomes a helper')
assert.equal(payload.installed_via, '')
assert.equal(payload.update_method, 'none')
assert.equal(payload.update_command, '')
assert.equal(payload.repo, '')
assert.equal(payload.upstream_type, '')
const hestia = fixture({nativeCandidate: {
...candidate, name: 'Hestia', default_ports: [8083], scheme: 'https',
}})
hestia.render(); await hestia.settle()
await hestia.button('searchApplications').props.onClick(); hestia.render()
await hestia.button('registerButton').props.onClick(); hestia.render()
await hestia.button('saveButton').props.onClick(); hestia.render()
const hestiaPayload = JSON.parse(hestia.calls.find(c => /\/apps$/.test(c.url) && c.method === 'POST').body)
assert.deepEqual(hestiaPayload.ports, [{port: 8083, scheme: 'https', web_path: ''}],
'a verified Hestia panel pre-fills only its fixed HTTPS endpoint')
const delayedCache = fixture()
delayedCache.holdInitialCache()
delayedCache.render(); await delayedCache.settle()
await delayedCache.button('searchApplications').props.onClick(); delayedCache.render()
assert.ok(text(delayedCache.tree()).includes('/opt/handmade/server'))
delayedCache.resolveInitialCache(); await delayedCache.settle()
assert.ok(text(delayedCache.tree()).includes('/opt/handmade/server'),
'a delayed initial cache read must not erase a completed explicit search')
const partial = fixture()
partial.render(); await partial.settle(); partial.setScanStatus('partial')
await partial.button('searchApplications').props.onClick(); partial.render()
assert.ok(text(partial.tree()).includes('nativeScanPartial'))
assert.ok(text(partial.tree()).includes('nativeEvidenceStale'))
const failed = fixture()
failed.render(); await failed.settle(); failed.setScanStatus('error')
await failed.button('searchApplications').props.onClick(); failed.render()
assert.ok(text(failed.tree()).includes('nativeScanFailed'))
assert.equal(failed.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0)
// Match the collector's public response, including retained stale evidence.
// A busy reply may carry an earlier guest_stopped issue from its cache.
for (const [status, issues, expected] of [
['error', ['guest_stopped'], 'nativeScanStopped'],
['error', ['status_unknown'], 'nativeScanFailed'],
['error', ['timeout'], 'nativeScanFailed'],
['error', [], 'nativeScanFailed'],
['error', undefined, 'nativeScanFailed'],
['error', 'guest_stopped', 'nativeScanFailed'],
['busy', ['guest_stopped'], 'nativeScanFailed'],
]) {
const denied = fixture()
denied.setNative({vmid: 101, status: 'complete', stale: true, candidates: [candidate]})
denied.render(); await denied.settle()
denied.setScanStatus(status); denied.setScanIssues(issues)
await denied.button('searchApplications').props.onClick(); denied.render()
assert.ok(text(denied.tree()).includes(expected), `${status}/${JSON.stringify(issues)} uses ${expected}`)
assert.ok(!text(denied.tree()).includes(expected === 'nativeScanStopped' ? 'nativeScanFailed' : 'nativeScanStopped'))
assert.ok(text(denied.tree()).includes('/opt/handmade/server'), 'failure retains prior evidence')
assert.equal(denied.calls.filter(c => c.method === 'POST').length, 1, 'only the explicit discovery POST, no suggestion, save or control call')
assert.equal(denied.cachedWrites.length, 0, 'failure cannot overwrite registrations')
assert.equal(denied.button('searchApplications').props.disabled, false, 'search settles and can be retried')
}
const stoppedLate = fixture()
stoppedLate.render(); await stoppedLate.settle()
stoppedLate.setScanStatus('error'); stoppedLate.setScanIssues(['guest_stopped'])
stoppedLate.holdScan()
const stoppedRequest = stoppedLate.button('searchApplications').props.onClick()
stoppedLate.render({vmid: 102}); await stoppedLate.settle()
stoppedLate.resolveScan(); await stoppedRequest; stoppedLate.render()
assert.ok(!text(stoppedLate.tree()).includes('nativeScanStopped'), 'late stopped response cannot label another CT')
const editorSwitch = fixture()
editorSwitch.render(); await editorSwitch.settle()
await editorSwitch.button('searchApplications').props.onClick(); editorSwitch.render()
await editorSwitch.button('registerButton').props.onClick(); editorSwitch.render()
editorSwitch.render({vmid: 102})
assert.ok(!text(editorSwitch.tree()).includes('saveButton'), 'old editor must disappear in the very first render of another CT')
const late = fixture()
late.render(); await late.settle(); late.holdScan()
const scan = late.button('searchApplications').props.onClick()
late.render({vmid: 102}); await late.settle()
late.resolveScan(); await scan; late.render()
assert.ok(!text(late.tree()).includes('/opt/handmade/server'), 'late A scan cannot appear in B')
assert.equal(late.cachedWrites.length, 0)
assert.equal(late.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0)
const saveLate = fixture()
saveLate.render(); await saveLate.settle()
await saveLate.button('searchApplications').props.onClick(); saveLate.render()
await saveLate.button('registerButton').props.onClick(); saveLate.render()
saveLate.holdSave(); const saving = saveLate.button('saveButton').props.onClick()
const priorWrites = saveLate.cachedWrites.length
saveLate.render({vmid: 102}); await saveLate.settle()
saveLate.resolveSave(); await saving; saveLate.render()
assert.equal(saveLate.cachedWrites.length, priorWrites, 'late save response does not update new target')
assert.equal(saveLate.calls.filter(c => /\/vms\/102\/apps$/.test(c.url) && c.method === 'POST').length, 0)
const oci = fixture()
oci.render({vmid: 101, oci: {instance: true}, initialData: {sidecar: {apps: []}, suggestions: {oci_instance: {template_id: 'image-app'}, extras: []}}})
await oci.settle()
await oci.button('refreshData').props.onClick()
assert.equal(oci.calls.filter(c => c.url.endsWith('/native-discovery') && c.method === 'POST').length, 0, 'OCI refresh never native-scans')
console.log('PASS native discovery: real panel Find → editor → Cancel/Save, no automatic scan, safe payload, CT switching and OCI refresh (fixtures)')
}
module.exports = {fixture, flatten, text, candidate}
if (require.main === module) main().catch(error => {console.error(error); process.exitCode = 1})