mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-15 19:26:45 +00:00
281 lines
17 KiB
JavaScript
281 lines
17 KiB
JavaScript
// Exercise the actual selector with a tiny JSX/hook harness; no browser or API.
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const vm = require('node:vm')
|
|
const {spawnSync} = require('node:child_process')
|
|
const os = require('node:os')
|
|
const root = path.resolve(__dirname, '../../AppImage')
|
|
const ts = require(path.join(root, 'node_modules/typescript'))
|
|
const source = fs.readFileSync(path.join(root, 'components/app-updater-editor.tsx'), 'utf8')
|
|
const result = ts.transpileModule(source, {compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
|
}, reportDiagnostics: true})
|
|
assert.equal(result.diagnostics.length, 0)
|
|
const flatten = node => !node || typeof node !== 'object' ? [] : [node, ...(
|
|
[node.props?.children].flat(Infinity).flatMap(flatten)
|
|
)]
|
|
|
|
// Check the real shared Button and cn/tailwind-merge, not only editor props.
|
|
function loadSource(relative) {
|
|
const compiled = ts.transpileModule(fs.readFileSync(path.join(root, relative), 'utf8'), {
|
|
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX},
|
|
})
|
|
const context = {exports: {}, require: name => name === '@/lib/utils'
|
|
? loadSource('lib/utils.ts') : require(path.join(root, 'node_modules', name))}
|
|
vm.runInNewContext(compiled.outputText, context)
|
|
return context.exports
|
|
}
|
|
const actualButton = loadSource('components/ui/button.tsx').Button
|
|
const navbarSource = fs.readFileSync(path.join(root, 'components/proxmox-dashboard.tsx'), 'utf8')
|
|
const appPanelSource = fs.readFileSync(path.join(root, 'components/lxc-app-panel.tsx'), 'utf8')
|
|
function assertSaveContrast(save) {
|
|
const dom = actualButton.render(save.props, null)
|
|
assert.equal(dom.props.disabled, save.props.disabled)
|
|
assert.match(dom.props.className, /\btext-white\b/)
|
|
assert.match(dom.props.className, /disabled:opacity-50/)
|
|
assert(!dom.props.className.includes('disabled:opacity-100'), 'retain the shared disabled appearance')
|
|
assert(!dom.props.className.includes('disabled:bg-blue-800'), 'do not override the disabled background')
|
|
const background = dom.props.className.split(' ').find(token => /^bg-blue-\d+$/.test(token))
|
|
assert.equal(background, 'bg-blue-500')
|
|
assert(navbarSource.includes(`data-[state=active]:${background}`), 'match the active navigation tab blue')
|
|
}
|
|
let englishEditor
|
|
|
|
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
|
|
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
|
|
let help = null
|
|
const t = key => {
|
|
const value = key.split('.').reduce((object, part) => object?.[part], messages)
|
|
assert.equal(typeof value, 'string', `${locale}: missing ${key}`)
|
|
return value
|
|
}
|
|
const context = {exports: {}, require: name => {
|
|
if (name === 'react') return {useId: () => 'test-command', useState: () => [help, value => {help = value}]}
|
|
if (name === 'react/jsx-runtime') return {jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})}
|
|
if (name === '@/lib/i18n/provider') return {useT: () => t}
|
|
return new Proxy({}, {get: (_, key) => String(key)})
|
|
}}
|
|
vm.runInNewContext(result.outputText, context)
|
|
if (locale === 'en') englishEditor = context.exports.AppUpdaterEditor
|
|
let saves = 0, method = 'none', command = ''
|
|
const props = {method, command, helperAvailable: true, helperSlug: 'qbittorrent', configured: false,
|
|
saving: false, changed: true, onMethodChange: value => {method = value}, onCommandChange: value => {command = value},
|
|
onSave: () => {saves++}, onCancel: () => {}, onRemove: () => {}}
|
|
const render = overrides => flatten(context.exports.AppUpdaterEditor({...props, method, command, ...overrides}))
|
|
let tree = render()
|
|
if (locale === 'es') assert.equal(t('vmLxc.updates.updaterChoiceHint'), 'Elige y guarda un método de actualización.')
|
|
assert.equal(tree.filter(n => n.props['aria-pressed'] === true).length, 0)
|
|
assert.equal(tree.filter(n => n.type === 'Textarea').length, 0)
|
|
let save = tree.find(n => n.type === 'Button' && n.props.onClick === props.onSave)
|
|
assert.equal(save.props.disabled, true)
|
|
assert.match(save.props.className, /bg-blue-500/)
|
|
assert.match(save.props.className, /hover:bg-blue-600/)
|
|
assert.match(save.props.className, /text-white/)
|
|
assertSaveContrast(save)
|
|
const helperButton = tree.find(n => n.type === 'Button' && n.props.children === t('vmLxc.updates.helperMethod'))
|
|
helperButton.props.onClick()
|
|
assert.equal(method, 'helper')
|
|
assert.equal(saves, 0, 'selecting must not execute or save')
|
|
tree = render()
|
|
const helperField = tree.find(n => n.type === 'Textarea')
|
|
const canonicalHelper = helperField.props.value
|
|
assert.equal(canonicalHelper, 'PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/qbittorrent.sh)"')
|
|
assert(!canonicalHelper.includes('_proxmenux_updater'), 'internal guards must not appear in the editor')
|
|
assert(!helperField.props.readOnly, 'the helper launcher must be editable')
|
|
helperField.props.onChange({target: {value: canonicalHelper}})
|
|
assert.equal(method, 'helper', 'an unchanged launcher remains the official method')
|
|
const editedHelper = canonicalHelper.replace('PHS_SILENT=1', 'PHS_SILENT=0')
|
|
helperField.props.onChange({target: {value: editedHelper}})
|
|
assert.equal(method, 'custom', 'editing must route execution through the saved custom command')
|
|
assert.equal(command, editedHelper)
|
|
assert.equal(render().find(n => n.type === 'Textarea').props.value, editedHelper)
|
|
helperButton.props.onClick()
|
|
assert.equal(render().find(n => n.type === 'Textarea').props.value, canonicalHelper)
|
|
assert.equal(command, editedHelper, 'switching back to helper preserves the unsaved custom draft')
|
|
command = ''
|
|
tree = render()
|
|
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, false)
|
|
assertSaveContrast(tree.find(n => n.props.onClick === props.onSave))
|
|
for (const overrides of [{saving: true}, {changed: false}]) {
|
|
const disabledSave = render(overrides).find(n => n.props.onClick === props.onSave)
|
|
assert.equal(disabledSave.props.disabled, true)
|
|
assertSaveContrast(disabledSave)
|
|
}
|
|
const helperInfo = tree.find(n => n.props['aria-label'] === t('vmLxc.updates.helperMethodHelp'))
|
|
assert.match(helperInfo.props.className, /text-blue-500/)
|
|
helperInfo.props.onClick()
|
|
tree = render()
|
|
assert.equal(tree.find(n => n.type === 'Dialog').props.open, true)
|
|
assert(tree.some(n => n.type === 'a' && n.props.href.endsWith('/ct/qbittorrent.sh')))
|
|
assert(tree.some(n => n.type === 'a' && n.props.href === 'https://community-scripts.org/docs/tools/pve/update-apps'))
|
|
const helperLinks = tree.filter(n => n.type === 'a')
|
|
assert.equal(helperLinks.length, 2)
|
|
for (const link of helperLinks) {
|
|
assert.match(link.props.className, /text-blue-400 hover:text-blue-300/)
|
|
assert(appPanelSource.includes('text-blue-400 hover:text-blue-300'), 'use the same colors as the App web links')
|
|
}
|
|
assert(tree.some(n => n.type === 'code' && n.props.children === canonicalHelper))
|
|
assert(!render({helperSlug: "bad'; touch /tmp/injected"}).some(n => n.type === 'code'))
|
|
assert(!render({helperSlug: undefined}).some(n => n.type === 'code'))
|
|
assert.equal(tree.filter(n => n.type === 'Textarea').length, 1)
|
|
assert.equal(render({helperSlug: undefined}).find(n => n.props.onClick === props.onSave).props.disabled, true)
|
|
const customInfo = tree.find(n => n.props['aria-label'] === t('vmLxc.updates.customMethodHelp'))
|
|
assert.match(customInfo.props.className, /text-blue-500/)
|
|
customInfo.props.onClick()
|
|
tree = render()
|
|
const headingIndex = tree.findIndex(n => n.type === 'p' && n.props.children === t('vmLxc.updates.customExamplesHeading'))
|
|
const descriptionIndex = tree.findIndex(n => n.type === 'DialogDescription')
|
|
const firstExampleIndex = tree.findIndex(n => n.type === 'code')
|
|
assert(headingIndex > descriptionIndex && headingIndex < firstExampleIndex, 'examples heading follows the introduction')
|
|
if (locale === 'es') assert.equal(t('vmLxc.updates.customExamplesHeading'), 'Ejemplos para adaptar:')
|
|
const examples = tree.filter(n => n.type === 'code').map(n => n.props.children)
|
|
assert.equal(examples.length, 4)
|
|
assert(examples.includes('/opt/my-app/update.sh'))
|
|
assert(examples[1].includes("curl -fsSL 'https://example.com/my-app/update.sh'"))
|
|
assert(examples[1].includes('-o "$script" &&\nbash "$script"'), 'never execute a failed download')
|
|
assert(examples[1].includes('trap'), 'clean up the temporary download')
|
|
for (const example of examples) {
|
|
const syntax = spawnSync('sh', ['-n'], {input: example, encoding: 'utf8'})
|
|
assert.equal(syntax.status, 0, syntax.stderr)
|
|
}
|
|
if (locale === 'en') {
|
|
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'proxmenux-helper-launcher-'))
|
|
try {
|
|
// The visible command stays short; the actual backend adds protection.
|
|
fs.symlinkSync('/bin/bash', path.join(fixture, 'bash'))
|
|
const fetch = '#!/bin/sh\nprintf "%s" "$FETCH_BODY"\nexit "$FETCH_STATUS"\n'
|
|
fs.writeFileSync(path.join(fixture, 'curl'), fetch, {mode: 0o755})
|
|
for (const tool of ['wget', 'curl']) {
|
|
const wget = path.join(fixture, 'wget')
|
|
if (tool === 'wget') fs.writeFileSync(wget, fetch, {mode: 0o755})
|
|
else fs.unlinkSync(wget)
|
|
const command = canonicalHelper.replaceAll('qbittorrent.sh', 'qbittorrent.sh2')
|
|
.replace('curl -fsSL', tool === 'wget' ? 'wget -qLO -' : 'curl -fsSL')
|
|
const prepared = spawnSync('python3', ['-c',
|
|
'import sys; from lxc_apps import protect_download_update_command; sys.stdout.write(protect_download_update_command(sys.stdin.read()))'], {
|
|
input: command, encoding: 'utf8', env: {...process.env, PYTHONPATH: path.join(root, 'scripts')},
|
|
})
|
|
assert.equal(prepared.status, 0, prepared.stderr)
|
|
assert(prepared.stdout.includes('updater download failed'), 'both curl and historical wget launchers remain protected')
|
|
for (const [status, body, expected] of [[8, '', 1], [8, 'echo SHOULD_NOT_RUN', 1],
|
|
[0, '', 1], [0, 'echo SCRIPT_RAN; exit 0', 0], [0, 'echo SCRIPT_RAN; exit 23', 23]]) {
|
|
const run = spawnSync('/bin/sh', ['-c', prepared.stdout], {
|
|
encoding: 'utf8', env: {...process.env, PATH: fixture, FETCH_STATUS: String(status), FETCH_BODY: body},
|
|
})
|
|
assert.equal(run.status, expected, run.stderr)
|
|
assert(!run.stdout.includes('SHOULD_NOT_RUN'), 'a partial failed download must not execute')
|
|
assert.equal(run.stdout.includes('SCRIPT_RAN'), status === 0 && body !== '')
|
|
}
|
|
}
|
|
} finally {
|
|
fs.rmSync(fixture, {recursive: true, force: true})
|
|
}
|
|
}
|
|
if (locale === 'en') {
|
|
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'proxmenux-online-example-'))
|
|
try {
|
|
const bin = path.join(fixture, 'bin')
|
|
fs.mkdirSync(bin)
|
|
fs.writeFileSync(path.join(bin, 'curl'), '#!/bin/sh\nexit "$EXAMPLE_FETCH_STATUS"\n', {mode: 0o755})
|
|
fs.writeFileSync(path.join(bin, 'bash'), '#!/bin/sh\n: > "$EXAMPLE_EXECUTION_MARKER"\n', {mode: 0o755})
|
|
for (const status of [0, 22]) {
|
|
const marker = path.join(fixture, `ran-${status}`)
|
|
const run = spawnSync('/bin/sh', ['-c', examples[1]], {encoding: 'utf8', env: {
|
|
...process.env, PATH: `${bin}:${process.env.PATH}`, TMPDIR: fixture,
|
|
EXAMPLE_FETCH_STATUS: String(status), EXAMPLE_EXECUTION_MARKER: marker,
|
|
}})
|
|
assert.equal(run.status, status, run.stderr)
|
|
assert.equal(fs.existsSync(marker), status === 0, 'do not run a failed or incomplete download')
|
|
}
|
|
assert.deepEqual(fs.readdirSync(fixture).sort(), ['bin', 'ran-0'], 'temporary scripts must be removed')
|
|
} finally {
|
|
fs.rmSync(fixture, {recursive: true, force: true})
|
|
}
|
|
}
|
|
assert(examples.some(code => code.includes('apt-get install -y --only-upgrade my-package')))
|
|
assert(examples.some(code => code.includes('install -b -m 0755 /tmp/my-app.new')))
|
|
assert.equal(command, '', 'help examples must not change the saved command')
|
|
assert.equal(saves, 0, 'opening help must not execute or save')
|
|
assert.match(tree.find(n => n.type === 'DialogContent').props.className, /overflow-y-auto/)
|
|
method = 'custom'
|
|
tree = render()
|
|
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, true)
|
|
tree.find(n => n.type === 'Textarea').props.onChange({target: {value: '/opt/my-app/update.sh'}})
|
|
tree = render()
|
|
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, false)
|
|
tree = render({helperAvailable: false})
|
|
assert(!tree.some(n => n.type === 'Button' && n.props.children === t('vmLxc.updates.helperMethod')))
|
|
method = 'helper'
|
|
tree = render({helperAvailable: false})
|
|
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, true)
|
|
console.log(`PASS ${locale}: explicit selection, navbar blue, dimmed disabled Save, examples heading, local/online scripts, no execution, validation`)
|
|
}
|
|
|
|
// Integrate the editor with the actual parent open/save/cancel functions.
|
|
async function testParentRoundTrip() {
|
|
const parentSource = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
|
|
const ast = ts.createSourceFile('virtual-machines.tsx', parentSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
const names = ['openCustomCmdEditor', 'saveCustomCommand', 'closeCustomCmdEditor']
|
|
const declarations = {}
|
|
function visit(node) {
|
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && names.includes(node.name.text)) {
|
|
declarations[node.name.text] = `const ${node.getText(ast)};`
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(ast)
|
|
for (const name of names) assert(declarations[name], `missing actual parent function ${name}`)
|
|
const compiled = ts.transpileModule(names.map(n => declarations[n]).join('\n') + '\n' +
|
|
names.map(n => `exports.${n} = ${n};`).join('\n'), {compilerOptions: {module: ts.ModuleKind.CommonJS}})
|
|
const saved = new Map([
|
|
['a', {id: 'a', name: 'Odoo', helper_slug: 'odoo', update_method: 'helper', update_command: ''}],
|
|
['b', {id: 'b', name: 'Other app', update_method: 'custom', update_command: '/opt/other/update.sh'}],
|
|
])
|
|
let writes = 0
|
|
const parent = {exports: {}, customCmdDraft: '', updaterMethodDraft: 'none',
|
|
setCustomCmdEditingApp: value => {parent.editing = value},
|
|
setCustomCmdDraft: value => {parent.customCmdDraft = value},
|
|
setUpdaterMethodDraft: value => {parent.updaterMethodDraft = value},
|
|
setCustomCmdSaving: value => {parent.saving = value},
|
|
patchAppWatch: async (vmid, app, patch) => {
|
|
assert.equal(vmid, 101)
|
|
saved.set(app.id, {...saved.get(app.id), ...patch})
|
|
writes++
|
|
}, t: key => key, alert: error => {throw Error(error)},
|
|
}
|
|
vm.runInNewContext(compiled.outputText, parent)
|
|
const render = () => flatten(englishEditor({method: parent.updaterMethodDraft, command: parent.customCmdDraft,
|
|
helperAvailable: true, helperSlug: saved.get(parent.editing)?.helper_slug, configured: true,
|
|
saving: false, changed: true, onMethodChange: parent.setUpdaterMethodDraft,
|
|
onCommandChange: parent.setCustomCmdDraft, onCancel: parent.exports.closeCustomCmdEditor,
|
|
onSave: () => {}, onRemove: () => {}}))
|
|
parent.exports.openCustomCmdEditor(saved.get('a'))
|
|
let field = render().find(n => n.type === 'Textarea')
|
|
assert(field.props.value.includes('/ct/odoo.sh'))
|
|
const edited = field.props.value.replace('PHS_SILENT=1', 'PHS_SILENT=0')
|
|
field.props.onChange({target: {value: edited}})
|
|
assert.equal(writes, 0, 'editing must not persist before Save')
|
|
parent.exports.closeCustomCmdEditor()
|
|
assert.equal(saved.get('a').update_method, 'helper', 'Cancel leaves the original choice unchanged')
|
|
parent.exports.openCustomCmdEditor(saved.get('a'))
|
|
field = render().find(n => n.type === 'Textarea')
|
|
assert(field.props.value.includes('PHS_SILENT=1'))
|
|
field.props.onChange({target: {value: edited}})
|
|
await parent.exports.saveCustomCommand(101, saved.get('a'))
|
|
assert.equal(saved.get('a').update_method, 'custom')
|
|
assert.equal(saved.get('a').update_command, edited, 'Save must not discard the edited helper launcher')
|
|
parent.exports.openCustomCmdEditor(saved.get('a'))
|
|
assert.equal(render().find(n => n.type === 'Textarea').props.value, edited, 'reopen the exact saved custom command')
|
|
parent.exports.openCustomCmdEditor(saved.get('b'))
|
|
assert.equal(render().find(n => n.type === 'Textarea').props.value, '/opt/other/update.sh', 'keep commands separate per app')
|
|
parent.exports.openCustomCmdEditor(saved.get('a'))
|
|
parent.setUpdaterMethodDraft('helper')
|
|
await parent.exports.saveCustomCommand(101, saved.get('a'))
|
|
assert.equal(saved.get('a').update_method, 'helper')
|
|
assert.equal(saved.get('a').update_command, '', 'explicitly selecting helper restores the official path')
|
|
assert.equal(saved.get('b').update_command, '/opt/other/update.sh')
|
|
console.log('PASS actual parent: edit, cancel, save, reopen, per-app isolation, restore helper')
|
|
}
|
|
testParentRoundTrip().catch(error => {console.error(error); process.exitCode = 1})
|