mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
feat: discover native LXC applications on demand with evidence
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// 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() {
|
||||
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = []
|
||||
const calls = [], cachedWrites = []
|
||||
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
|
||||
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete'
|
||||
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
|
||||
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) {cursor++; return fn()}, useCallback(fn) {cursor++; return fn},
|
||||
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 (url.endsWith('/native-discovery')) {
|
||||
if (options.method === 'POST') {
|
||||
if (scanGate) await scanGate
|
||||
native = {...native, status: scanStatus, candidates: [candidate]}
|
||||
}
|
||||
return structuredClone(native)
|
||||
}
|
||||
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 () => seed,
|
||||
subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args),
|
||||
}
|
||||
const exports = {}
|
||||
vm.runInNewContext(compiled.outputText, {exports, console, setTimeout, clearTimeout,
|
||||
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-apps-cache')) return cache
|
||||
if (name.includes('i18n/provider')) return {useT: () => (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
|
||||
}
|
||||
const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()}
|
||||
const button = key => {
|
||||
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key))
|
||||
assert.ok(match, 'Missing button ' + key)
|
||||
return match
|
||||
}
|
||||
return {render, settle, calls, cachedWrites, button, tree: () => tree,
|
||||
setNative: value => {native = value},
|
||||
setScanStatus: value => {scanStatus = 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 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)
|
||||
|
||||
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)')
|
||||
}
|
||||
main().catch(error => {console.error(error); process.exitCode = 1})
|
||||
Reference in New Issue
Block a user