mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge develop and preserve Slovak Monitor and CLI messages
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// Real transport templates and actual fetchApi, with no network/guest access.
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const {execFileSync} = require('node:child_process')
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const app = path.join(root, 'AppImage')
|
||||
const ts = require(path.join(app, 'node_modules/typescript'))
|
||||
function load(relative, extra = {}) {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(path.join(app, relative), 'utf8'), {
|
||||
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020}, reportDiagnostics: true,
|
||||
})
|
||||
assert.deepEqual(compiled.diagnostics, [])
|
||||
const context = {exports: {}, process: {env: {}}, console: {error() {}}, ...extra}
|
||||
vm.runInNewContext(compiled.outputText, context)
|
||||
return context.exports
|
||||
}
|
||||
const {localizeAppEditorError: localize} = load('lib/lxc-app-editor-errors.ts')
|
||||
const catalogs = Object.fromEntries(['en', 'sk', 'de'].map(locale => [locale,
|
||||
JSON.parse(fs.readFileSync(path.join(app, `messages/${locale}/common.json`)))]))
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const lookup = data => key.split('.').reduce((item, segment) => item?.[segment], data)
|
||||
const template = lookup(catalogs[locale]) ?? lookup(catalogs.en)
|
||||
assert.equal(typeof template, 'string', 'Missing translation ' + key)
|
||||
return template.replace(/\{(\w+)\}/g, (all, key) => params[key] ?? all)
|
||||
}
|
||||
// A locale without these keys must show the English backend text.
|
||||
catalogs.missing = {}
|
||||
const en = translate('en'), sk = translate('sk')
|
||||
// Enumerate only pure validator ASTs; importing lxc_apps would access host state.
|
||||
const contract = JSON.parse(execFileSync('python3', ['-c', String.raw`
|
||||
import ast,json,re,typing,sys
|
||||
tree=ast.parse(open(sys.argv[1]).read())
|
||||
names={'_err','_validate_command_argv','_validate_binary_args','_validate_ports','validate_config'}
|
||||
ns={'re':re,'Any':typing.Any,'Optional':typing.Optional}
|
||||
for node in tree.body:
|
||||
if isinstance(node,ast.Assign) and len(node.targets)==1 and isinstance(node.targets[0],ast.Name) and node.targets[0].id.startswith('_'):
|
||||
try: value=ast.literal_eval(node.value)
|
||||
except (ValueError,TypeError):
|
||||
if not (isinstance(node.value,ast.Call) and isinstance(node.value.func,ast.Attribute) and isinstance(node.value.func.value,ast.Name) and node.value.func.value.id=='re' and node.value.func.attr=='compile'): continue
|
||||
args=[getattr(re,a.attr) if isinstance(a,ast.Attribute) and isinstance(a.value,ast.Name) and a.value.id=='re' else ast.literal_eval(a) for a in node.value.args]
|
||||
value=re.compile(*args)
|
||||
ns[node.targets[0].id]=value
|
||||
functions=[n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name in names]
|
||||
exec(compile(ast.Module(body=functions,type_ignores=[]),'<pure validators>','exec'),ns)
|
||||
messages=set()
|
||||
context={**ns,'i':3,'p':8080,'field':'repo','e':'missing ], position 4\npattern: v?(test'}
|
||||
for function in functions:
|
||||
for n in ast.walk(function):
|
||||
if isinstance(n,ast.Call) and isinstance(n.func,ast.Name) and n.func.id=='_err' and n.args and isinstance(n.args[0],(ast.Constant,ast.JoinedStr)):
|
||||
messages.add(eval(compile(ast.Expression(n.args[0]),'<contract>','eval'),context))
|
||||
cases=[[],{}, {'name':'Test','installed_via':'file','file_path':'relative'},
|
||||
{'name':'Test','installed_via':'file','file_path':'/fiction/version','file_regex':'['},
|
||||
{'name':'Test','installed_via':'command','command_argv':['test','bad\narg']},
|
||||
{'name':'Test','ports':[{'port':8080},{'port':8080}]},
|
||||
{'name':'Test','installed_via':'manual'}]
|
||||
errors=[]
|
||||
for case in cases:
|
||||
ok,error=ns['validate_config'](case)
|
||||
assert not ok
|
||||
errors.append(error)
|
||||
print(json.dumps({'templates':sorted(messages),'actual':errors}))
|
||||
`, path.join(app, 'scripts/lxc_apps.py')], {encoding: 'utf8'}))
|
||||
assert.equal(contract.templates.length, 69, 'Update the contract inventory when validation changes')
|
||||
for (const message of [...contract.templates, ...contract.actual]) {
|
||||
assert.equal(localize(message, en), message, 'EN must preserve the exact backend message')
|
||||
assert.notEqual(localize(message, sk), message, 'Untranslated backend validation: ' + message)
|
||||
assert.equal(localize(message, translate('missing')), message, 'Missing locale falls back to EN')
|
||||
}
|
||||
// Every supported message, including detector errors, preserves interpolated data.
|
||||
const enErrors = catalogs.en.vmLxc.appEditor.errors, skErrors = catalogs.sk.vmLxc.appEditor.errors
|
||||
assert.deepEqual(Object.keys(enErrors).sort(), Object.keys(skErrors).sort())
|
||||
for (const [key, template] of Object.entries(enErrors)) {
|
||||
const params = Object.fromEntries([...template.matchAll(/\{(\w+)\}/g)].map(([, name]) => [name,
|
||||
['index','port','max','code'].includes(name) ? '17' : name === 'repo' ? 'owner/project' : "detail /opt/app --version {literal} <tag>\nnext line"]))
|
||||
const message = en(`vmLxc.appEditor.errors.${key}`, params)
|
||||
assert.equal(localize(message, en), message)
|
||||
assert.equal(localize(message, sk), sk(`vmLxc.appEditor.errors.${key}`, params), key)
|
||||
}
|
||||
assert.equal(localize('binary_args[4] exceeds 256 chars', sk), 'Argument binary_args[4] môže mať najviac 256 znakov.')
|
||||
assert.equal(localize('tag_regex is not a valid regex: [bad]\nposition 4', sk), 'Regulárny výraz pre tagy (tag_regex) je neplatný: [bad]\nposition 4')
|
||||
for (const message of ['unknown /opt/app --version', 'myapp: not found', 'sh: myapp: not found', '<script>alert(1)</script>', 'file_regex is required extra', 'prefix file_regex is required', 'binary_args[-1] exceeds 4 chars']) {
|
||||
assert.equal(localize(message, sk), message, 'Unknown detail must remain intact')
|
||||
}
|
||||
for (const empty of [null, undefined, '']) assert.equal(localize(empty, sk), '')
|
||||
assert.equal(catalogs.sk.vmLxc.appEditor.commandPlaceholder, catalogs.en.vmLxc.appEditor.commandPlaceholder)
|
||||
assert.equal(catalogs.sk.vmLxc.appEditor.regexPlaceholderVersion, catalogs.en.vmLxc.appEditor.regexPlaceholderVersion)
|
||||
async function main() {
|
||||
const {fixture, flatten, text} = require('./test_native_app_discovery_ui.cjs')
|
||||
const recorded = {id: 'fiction', name: 'Fixture', installed_via: 'file', file_path: 'relative', ports: [], state: {}}
|
||||
for (const locale of ['en', 'sk']) {
|
||||
const t = translate(locale)
|
||||
const f = fixture({apps: [recorded], translate: t, apiError: 'file_path is required and must be an absolute path'})
|
||||
f.render(); await f.settle()
|
||||
const mode = flatten(f.tree()).find(n => n.type === 'button' && text(n).trim() === t('vmLxc.appEditor.editButton'))
|
||||
assert.ok(mode); mode.props.onClick(); f.render()
|
||||
const action = key => flatten(f.tree()).find(n => n.type === 'button' && text(n).trim() === t('vmLxc.appEditor.' + key))
|
||||
await action('removeButton').props.onClick(); f.render()
|
||||
assert.deepEqual(f.confirmPrompts, [t('vmLxc.appEditor.confirmRemove')])
|
||||
assert.equal(f.calls.filter(c => c.method === 'DELETE').length, 0)
|
||||
assert.equal(f.cachedWrites.length, 0, 'decline leaves cache untouched')
|
||||
await action('editFieldsButton').props.onClick(); f.render()
|
||||
await f.button('saveButton').props.onClick(); f.render()
|
||||
assert.ok(text(f.tree()).includes(t('vmLxc.appEditor.errors.filePath')))
|
||||
assert.equal(flatten(f.tree()).find(n => n.props?.id === 'app-file-path').props.value, 'relative')
|
||||
assert.equal(f.calls.filter(c => c.method === 'PUT').length, 1)
|
||||
assert.equal(f.cachedWrites.length, 0, 'failed save preserves cached record')
|
||||
await f.button('testDetectorButton').props.onClick(); f.render()
|
||||
assert.ok(text(f.tree()).includes(t('vmLxc.appEditor.errors.filePath')))
|
||||
const before = f.calls.filter(c => c.method && c.method !== 'GET').length
|
||||
f.button('cancelButton').props.onClick(); f.render()
|
||||
assert.equal(f.calls.filter(c => c.method && c.method !== 'GET').length, before)
|
||||
assert.ok(text(f.tree()).includes('Fixture'))
|
||||
}
|
||||
let requests = 0
|
||||
const backend = 'file_path is required and must be an absolute path'
|
||||
const {fetchApi} = load('lib/api-config.ts', {fetch: async () => {
|
||||
requests++
|
||||
return {ok: false, status: 400, statusText: 'BAD REQUEST', headers: {get: () => 'application/json'}, json: async () => ({error: backend})}
|
||||
}})
|
||||
for (const [method, route] of [['POST','/api/vms/999999/apps'], ['PUT','/api/vms/999999/apps/fake'], ['POST','/api/vms/999999/apps/test']]) {
|
||||
try {await fetchApi(route, {method, body: '{}'}); assert.fail('Expected validation failure')}
|
||||
catch (error) {
|
||||
assert.equal(error.message, backend)
|
||||
assert.equal(localize(error.message, sk), 'Zadaj úplnú cestu k súboru (file_path), začínajúcu lomkou /.')
|
||||
}
|
||||
}
|
||||
assert.equal(requests, 3)
|
||||
console.log(`PASS app editor: ${contract.templates.length} backend validation templates, ${Object.keys(enErrors).length} EN/SK rules, real fetchApi POST/PUT/test fixtures; external calls 0`)
|
||||
}
|
||||
main().catch(error => {console.error(error); process.exitCode = 1})
|
||||
@@ -23,9 +23,10 @@ const candidate = {
|
||||
evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [],
|
||||
}
|
||||
|
||||
function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []} = {}) {
|
||||
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 = []
|
||||
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}
|
||||
@@ -56,6 +57,8 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []
|
||||
}
|
||||
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
|
||||
@@ -96,12 +99,20 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []
|
||||
}
|
||||
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: () => (key, values) => key + (values ? JSON.stringify(values) : '')}
|
||||
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})
|
||||
@@ -136,11 +147,12 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []
|
||||
}
|
||||
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))
|
||||
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, button, tree: () => tree, bundleReads: () => bundleReads,
|
||||
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},
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Exercise the shipped error adapter and actual HostBackup handlers with a fake transport.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const { ts, read, compile, translator } = require('./backup_i18n_seam.cjs');
|
||||
const library = { exports: {} };
|
||||
vm.runInNewContext(compile(read('lib/host-backup-messages.ts')), library);
|
||||
const { hostBackupErrorText } = library.exports;
|
||||
// Independently read the real API's literal/f-string contracts without importing
|
||||
// Flask or running any route, host probe, script or persistence operation.
|
||||
const contracts = JSON.parse(require('node:child_process').execFileSync('python3', ['-I', '-B', '-c', `
|
||||
import ast,json,sys
|
||||
tree=ast.parse(open(sys.argv[1]).read())
|
||||
def messages(node):
|
||||
if isinstance(node,ast.Constant) and isinstance(node.value,str): return [node.value]
|
||||
if isinstance(node,ast.JoinedStr): return [''.join(part.value if isinstance(part,ast.Constant) else 'PARAMETER' for part in node.values)]
|
||||
if isinstance(node,ast.BoolOp): return [text for child in node.values for text in messages(child)]
|
||||
return []
|
||||
result=set()
|
||||
for function in tree.body:
|
||||
if isinstance(function,ast.FunctionDef) and function.name.startswith(('api_host_backups_','api_host_restore')):
|
||||
for node in ast.walk(function):
|
||||
if isinstance(node,ast.Dict):
|
||||
for key,value in zip(node.keys,node.values):
|
||||
if isinstance(key,ast.Constant) and key.value=='error': result.update(messages(value))
|
||||
print(json.dumps(sorted(result)))
|
||||
`, 'AppImage/scripts/flask_server.py'], { encoding: 'utf8' }));
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? [n, ...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale) {
|
||||
let cursor = 0, slots = [], tree, failure = 'path must be absolute', mutations = 0;
|
||||
const calls = [], t = translator(locale);
|
||||
const jsx = (type, props) => typeof type === 'function' ? type(props) : { type, props };
|
||||
const hooks = { useState(initial) { const i = cursor++; if (!(i in slots)) slots[i] = initial; return [slots[i], value => { slots[i] = value; }]; } };
|
||||
const requireModule = name => {
|
||||
if (name === 'react') return hooks;
|
||||
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx };
|
||||
if (name === 'swr') return { default: () => ({ data: { paths: [{ path: '/kept' }] }, mutate: () => { mutations++; } }) };
|
||||
if (name.includes('i18n/provider')) return { useT: () => t };
|
||||
if (name.endsWith('host-backup-messages')) return library.exports;
|
||||
if (name.endsWith('api-config')) return { fetchApi: async (url, options) => { calls.push({ url, ...options }); if (failure) throw new Error(failure); return { status: 'ok' }; } };
|
||||
return new Proxy({}, { get: (_, key) => key });
|
||||
};
|
||||
const source = read('components/host-backup.tsx') + '\nexports.ExtraPathsSection = ExtraPathsSection;';
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX } }).outputText,
|
||||
{ exports: module.exports, require: requireModule, Error, console });
|
||||
return { calls, t, render() { cursor = 0; tree = module.exports.ExtraPathsSection(); return tree; }, text: () => text(tree),
|
||||
input: () => flatten(tree).find(n => n.type === 'Input'),
|
||||
add: () => flatten(tree).find(n => n.type === 'Button' && text(n).includes(t('backup.actions.addPath'))),
|
||||
fail: message => { failure = message; }, mutations: () => mutations };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
assert.ok(contracts.length > 100, 'real HostBackup error contracts discovered');
|
||||
for (const message of contracts) {
|
||||
const translated = hostBackupErrorText(message, translator('sk'));
|
||||
assert.notEqual(translated, message, 'application-owned message must be localized: ' + message);
|
||||
assert.ok(!translated.includes('backup.apiErrors.'), 'every mapped key has a real Slovak value');
|
||||
assert.equal(hostBackupErrorText(message, translator('missing')), hostBackupErrorText(message, translator('en')), 'missing locale uses English');
|
||||
}
|
||||
for (const locale of ['en', 'sk', 'missing']) {
|
||||
const t = translator(locale);
|
||||
assert.equal(hostBackupErrorText(new Error('path must be absolute'), t), t('backup.apiErrors.absolutePath'));
|
||||
assert.equal(hostBackupErrorText({ message: 'this path is already configured' }, t), t('backup.apiErrors.pathConfigured'));
|
||||
assert.equal(hostBackupErrorText('job already exists: test_1', t), t('backup.apiErrors.jobExists', { value: 'test_1' }));
|
||||
const raw = 'tool failed: <img src=x onerror=unsafe()> path must be absolute';
|
||||
assert.equal(hostBackupErrorText(raw, t), raw, 'external diagnostics must not be partially matched or discarded');
|
||||
const long = 'path does not exist on this host: ' + 'x'.repeat(20000);
|
||||
assert.equal(hostBackupErrorText(long, t), long, 'large diagnostics preserved without pattern matching');
|
||||
for (const unknown of ['constructor', '__proto__', 'toString', 'future backend message']) assert.equal(hostBackupErrorText(unknown, t), unknown);
|
||||
for (const invalid of [null, undefined, {}, 23]) assert.equal(hostBackupErrorText(invalid, t), t('backup.apiErrors.requestFailed'));
|
||||
const unsafe = '<img src=x onerror=unsafe()>{backup.actions.run}';
|
||||
assert.equal(hostBackupErrorText('path does not exist on this host: ' + unsafe, t), t('backup.apiErrors.pathMissing', { value: unsafe }), 'captured paths remain literal data');
|
||||
const f = fixture(locale); f.render();
|
||||
assert.equal(f.add().props.disabled, true);
|
||||
await f.add().props.onClick(); assert.equal(f.calls.length, 0, 'blank path causes no request');
|
||||
f.input().props.onChange({ target: { value: 'relative-path' } }); f.render(); await f.add().props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 1); assert.equal(f.calls[0].url, '/api/host-backups/extra-paths');
|
||||
assert.equal(f.calls[0].method, 'POST'); assert.deepEqual(JSON.parse(f.calls[0].body), { path: 'relative-path' });
|
||||
assert.equal(f.input().props.value, 'relative-path'); assert.equal(f.mutations(), 0, 'rejected input preserves state and does not refresh as success');
|
||||
assert.ok(f.text().includes(t('backup.apiErrors.absolutePath'))); assert.ok(f.text().includes('/kept'));
|
||||
f.fail('future backend message'); await f.add().props.onClick(); f.render(); assert.ok(f.text().includes('future backend message'));
|
||||
f.fail(null); await f.add().props.onClick(); f.render(); assert.equal(f.input().props.value, ''); assert.equal(f.mutations(), 1);
|
||||
}
|
||||
// Adapter has no effect on the server's CLI/API/persistence contracts.
|
||||
const host = read('components/host-backup.tsx');
|
||||
const ast = ts.createSourceFile('host-backup.tsx', host, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), encryptionCatches = [];
|
||||
function visit(node) { if (ts.isCatchClause(node) && node.block.getText(ast).includes('err.body?.tool_output')) encryptionCatches.push(node.block); ts.forEachChild(node, visit); }
|
||||
visit(ast); assert.equal(encryptionCatches.length, 2, 'scheduled and manual backup encryption paths both checked');
|
||||
for (const locale of ['en', 'sk', 'missing']) for (const block of encryptionCatches) {
|
||||
let displayed, effects = 0;
|
||||
const code = ts.transpileModule('function run(e) '+block.getText(ast)+'; run(error);', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const t = translator(locale), error = Object.assign(new Error('failed to create PBS encryption keyfile'), { body: { tool_output: 'EXTERNAL TOOL DIAGNOSTIC' } });
|
||||
vm.runInNewContext(code, { error, t, hostBackupErrorText, setError: value => { displayed = value; }, setPbsImportBusy: () => {}, setSubmitting: () => {}, onCreated: () => effects++ });
|
||||
assert.ok(displayed.includes(hostBackupErrorText(error, t)) && displayed.includes('EXTERNAL TOOL DIAGNOSTIC'));
|
||||
assert.ok(!displayed.includes('{error}') && !displayed.includes('{detail}'), 'error interpolation must not leave an unresolved placeholder');
|
||||
assert.equal(effects, 0);
|
||||
}
|
||||
assert.ok(!host.includes('e instanceof Error ? e.message : String(e)'), 'all HostBackup catch consumers use the adapter');
|
||||
assert.ok(!host.includes('{e.error}') && !host.includes('{calendarPreview.error}'), 'structured error consumers use the adapter too');
|
||||
console.log('PASS: '+contracts.length+' real API contracts, HostBackup actual extra-path handlers, EN/SK/fallback, 0 calls for blank, rejected input/state, success, literal parameters and unknown diagnostics');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,133 @@
|
||||
// Run the shipped translation adapter, transport and both dialog components.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
||||
}}).outputText;
|
||||
const translations = Object.fromEntries(['en', 'sk'].map(locale => [locale, JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], translations[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (match, key) => params[key] ?? match);
|
||||
};
|
||||
const adapter = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/monitor-link-errors.ts', 'utf8')), adapter);
|
||||
const {monitorLinkError} = adapter.exports;
|
||||
const codes = {
|
||||
monitor_link_address_required: 'addressRequired', monitor_link_invalid_address: 'invalidAddress',
|
||||
monitor_link_address_credentials: 'addressCredentials', monitor_link_invalid_port: 'invalidPort',
|
||||
monitor_link_invalid_request: 'invalidRequest', monitor_link_duplicate: 'duplicate',
|
||||
monitor_link_not_found: 'notFound', monitor_link_detected_remove: 'detectedRemove',
|
||||
};
|
||||
const failure = (code, params) => Object.assign(new Error('RAW ENGLISH SECRET DETAILS'), {body: {error: 'RAW ENGLISH SECRET DETAILS', code, params}});
|
||||
|
||||
const flatten = tree => !tree || typeof tree === 'boolean' ? [] : Array.isArray(tree) ? tree.flatMap(flatten)
|
||||
: typeof tree === 'object' ? [tree, ...flatten(tree.props?.children)] : [tree];
|
||||
const text = tree => flatten(tree).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
const jsx = (type, props) => ({type, props});
|
||||
const source = compile(fs.readFileSync('AppImage/components/monitor-switcher.tsx', 'utf8') + '\nexport {ManageLinksDialog};');
|
||||
|
||||
function fixture({locale = 'sk', links = [], origin = false, loadError = false, loading = false} = {}) {
|
||||
let slots = [], cursor = 0, effects = [], tree, responseError, refreshFailure = false;
|
||||
const calls = [], refreshes = [], storage = new Map();
|
||||
const candidates = [{u: 'https://other.test', n: 'Other', p: 'pve'}];
|
||||
const carried = Buffer.from(JSON.stringify(candidates)).toString('base64url');
|
||||
const location = {origin: 'https://current.test', hostname: 'current.test', pathname: '/', hash: '',
|
||||
search: origin ? `?pmx_from=https%3A%2F%2Fsource.test&pmx_name=Source&pmx_links=${carried}` : ''};
|
||||
const react = {
|
||||
useState(initial) {const slot = cursor++; if (!(slot in slots)) slots[slot] = typeof initial === 'function' ? initial() : initial;
|
||||
return [slots[slot], value => {slots[slot] = typeof value === 'function' ? value(slots[slot]) : value;}];},
|
||||
useEffect(fn, deps) {const slot = cursor++; const old = slots[slot];
|
||||
if (!old || deps.some((dep, i) => dep !== old[i])) effects.push(fn);
|
||||
slots[slot] = deps;
|
||||
},
|
||||
};
|
||||
const exported = {};
|
||||
vm.runInNewContext(source, {exports: exported, URL, URLSearchParams, escape, unescape,
|
||||
atob: value => Buffer.from(value, 'base64').toString('binary'), btoa: value => Buffer.from(value, 'binary').toString('base64'),
|
||||
window: {location, history: {replaceState() {location.search = ''}}},
|
||||
sessionStorage: {getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value)},
|
||||
require(name) {
|
||||
if (name === 'react') return react;
|
||||
if (name === 'react/jsx-runtime') return {jsx, jsxs: jsx, Fragment: 'Fragment'};
|
||||
if (name === 'swr') return {default: () => ({data: {self: {name: 'Current', product: 'pve'}, links}, error: loadError, isLoading: loading,
|
||||
mutate: async () => refreshes.push('all')}), mutate: async key => {refreshes.push(key); if (refreshFailure) throw new Error('RAW REFRESH ERROR');}};
|
||||
if (name.includes('i18n/provider')) return {useT: () => translate(locale)};
|
||||
if (name.includes('monitor-link-errors')) return {monitorLinkError};
|
||||
if (name.includes('api-config')) return {fetchApi: async (path, options) => {calls.push({path, ...options}); if (responseError) throw responseError; return {success: true};}};
|
||||
return new Proxy({}, {get: (_, key) => key});
|
||||
},
|
||||
});
|
||||
return {
|
||||
render(component = 'ManageLinksDialog') {cursor = 0; effects = []; tree = exported[component]({open: true, onOpenChange() {}, nodeName: 'Current'}); effects.forEach(fn => fn()); return tree;},
|
||||
button(label) {const node = flatten(tree).find(n => n.type === 'Button' && (text(n) === label || n.props['aria-label'] === label)); assert.ok(node, label); return node;},
|
||||
input(id) {const node = flatten(tree).find(n => n.type === 'Input' && (n.props.id === id || n.props['aria-label'] === id)); assert.ok(node, id); return node;},
|
||||
nodes: () => flatten(tree), text: () => text(tree), fail: error => {responseError = error;}, failRefresh: () => {refreshFailure = true;}, calls, refreshes, storage,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const locale of ['en', 'sk']) {
|
||||
const t = translate(locale);
|
||||
for (const [code, key] of Object.entries(codes)) assert.equal(monitorLinkError(failure(code), t), t(`monitorLinks.errors.${key}`));
|
||||
assert.equal(monitorLinkError(failure('monitor_link_limit', {count: 64}), t), t('monitorLinks.errors.limit', {count: 64}));
|
||||
for (const error of [null, undefined, 'raw', new Error('raw'), {body: null}, {body: {}},
|
||||
...['constructor', '__proto__', 'toString', 'unknown', 'monitor_links_save_failed'].map(code => failure(code)),
|
||||
...[undefined, null, '64', 0, -1, 1.5, NaN, Infinity, 2 ** 54].map(count => failure('monitor_link_limit', {count}))]) {
|
||||
assert.equal(monitorLinkError(error, t), t('monitorLinks.saveFailed'));
|
||||
}
|
||||
const f = fixture({locale, links: [{key: 'manual:saved', name: 'Saved', url: 'https://saved.test', manual: true}]});
|
||||
f.render(); f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://unsaved.test'}}); f.render();
|
||||
f.button(t('monitorLinks.cancel')).props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 0, 'Cancel must not write');
|
||||
f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
assert.equal(f.input(t('monitorLinks.address')).props.value, 'https://saved.test', 'Cancel discards address draft');
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://changed.test'}}); f.render();
|
||||
await f.button(t('monitorLinks.save')).props.onClick(); f.render();
|
||||
assert.equal(f.calls[0].method, 'PATCH'); assert.deepEqual(JSON.parse(f.calls[0].body), {url: 'https://changed.test'});
|
||||
assert.equal(f.button(t('monitorLinks.add')).props.disabled, true, 'Blank address cannot be submitted');
|
||||
f.input('monitor-link-url').props.onChange({target: {value: 'ftp://bad.test'}}); f.render();
|
||||
f.fail(failure('monitor_link_invalid_address')); await f.button(t('monitorLinks.add')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.errors.invalidAddress')));
|
||||
assert.equal(f.input('monitor-link-url').props.value, 'ftp://bad.test', 'Failure retains draft');
|
||||
assert.ok(!f.text().includes('RAW ENGLISH'));
|
||||
f.fail(new Error('raw network details')); await f.button(t('monitorLinks.remove')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.saveFailed')));
|
||||
const failedRead = fixture({locale, loadError: new Error('raw')}); failedRead.render();
|
||||
assert.ok(failedRead.text().includes(t('monitorLinks.loadFailed')));
|
||||
const pendingRead = fixture({locale, loading: true}); pendingRead.render();
|
||||
assert.ok(pendingRead.text().includes(t('monitorLinks.checking')));
|
||||
|
||||
const declined = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) declined.render('MonitorSwitcher');
|
||||
declined.button(t('monitorLinks.notNow')).props.onClick(); declined.render('MonitorSwitcher');
|
||||
assert.equal(declined.calls.length, 0, 'Not now must not write');
|
||||
const carried = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) carried.render('MonitorSwitcher');
|
||||
const boxes = carried.nodes().filter(n => n.type === 'Checkbox'); assert.equal(boxes.length, 2);
|
||||
boxes[1].props.onCheckedChange(false); carried.render('MonitorSwitcher');
|
||||
carried.fail(failure('monitor_link_duplicate'));
|
||||
carried.failRefresh();
|
||||
await carried.button(t('monitorLinks.addSelected')).props.onClick(); carried.render('MonitorSwitcher');
|
||||
assert.equal(carried.calls.length, 1, 'Only selected origin is attempted');
|
||||
assert.equal(JSON.parse(carried.calls[0].body).url, 'https://source.test');
|
||||
assert.ok(carried.text().includes(t('monitorLinks.errors.duplicate')));
|
||||
assert.equal(carried.refreshes.length, 1, 'Failed/partially successful batch refreshes the visible list');
|
||||
}
|
||||
// Real authenticated transport preserves additive fields for the adapter.
|
||||
const api = {exports: {}, process: {env: {}}, fetch: async () => new Response(JSON.stringify({error: 'Original English error', code: 'monitor_link_duplicate'}), {status: 400, headers: {'Content-Type': 'application/json'}})};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/api-config.ts', 'utf8')), api);
|
||||
try {await api.exports.fetchApi('/api/monitor-links', {method: 'POST'}); assert.fail('expected rejection');}
|
||||
catch (error) {assert.equal(error.status, 400); assert.equal(error.message, 'Original English error'); assert.equal(monitorLinkError(error, translate('sk')), translations.sk.monitorLinks.errors.duplicate);}
|
||||
const en = translations.en.monitorLinks, sk = translations.sk.monitorLinks;
|
||||
assert.deepEqual(Object.keys(en.errors).sort(), Object.keys(sk.errors).sort());
|
||||
for (const key of Object.keys(en.errors)) {
|
||||
assert.ok(sk.errors[key]);
|
||||
assert.deepEqual(en.errors[key].match(/\{\w+\}/g), sk.errors[key].match(/\{\w+\}/g), key);
|
||||
}
|
||||
console.log('PASS: EN/SK known/unknown errors, real transport, both dialogs, Cancel/Not now zero writes, draft persistence, selected-only batch, localized loading/failure');
|
||||
}
|
||||
main().catch(error => {console.error(error); process.exitCode = 1;});
|
||||
@@ -0,0 +1,157 @@
|
||||
// Execute the real WebSocket handlers and the OCI caller's actual close callback.
|
||||
const assert = require('node:assert/strict'), vm = require('node:vm');
|
||||
const {ts,read,compile,translator,catalogs} = require('./backup_i18n_seam.cjs');
|
||||
const moduleScope = {exports:{}};
|
||||
vm.runInNewContext(compile(read('lib/script-completion.ts')), moduleScope);
|
||||
const completion = moduleScope.exports;
|
||||
const source = ts.createSourceFile('modal.tsx', read('components/script-terminal-modal.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const handlers = []; let messageCallback,reconnectCallback;
|
||||
function walk(node) {
|
||||
if (ts.isBinaryExpression(node) && node.left.getText(source) === 'ws.onmessage') handlers.push(node.right);
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'getCompletionMessage') messageCallback = node.initializer.arguments[0];
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'attemptReconnect') reconnectCallback = node.initializer.arguments[0];
|
||||
ts.forEachChild(node, walk);
|
||||
}
|
||||
walk(source);
|
||||
assert.equal(handlers.length,2,'initial and reconnect handlers');
|
||||
assert.ok(messageCallback);
|
||||
function executable(node, file, scope) {
|
||||
const local = {...scope,exports:{}};
|
||||
vm.runInNewContext(compile('exports.fn = '+node.getText(file)+';'),local);
|
||||
return local.exports.fn;
|
||||
}
|
||||
function fixture(handler, locale, optedIn=true) {
|
||||
const writes=[], callbacks=[], state={closed:0};
|
||||
const t=translator(locale), terminal={write:s=>writes.push(s),writeln:s=>writes.push(s)};
|
||||
const shared={...completion, cancelledExitCode:optedIn?6:undefined,t,
|
||||
completedSuccessfullyMessage:undefined,completedWithErrorMessage:undefined};
|
||||
const getCompletionMessage=executable(messageCallback,source,shared);
|
||||
const scope={...shared,getCompletionMessage,term:terminal,termRef:{current:terminal},
|
||||
completionReceivedRef:{current:false},onCompleteRef:{current:code=>callbacks.push(code)},
|
||||
setIsComplete:v=>state.complete=v,ws:{readyState:1,close:()=>state.closed++},WebSocket:{OPEN:1},
|
||||
setIsWaitingNextInteraction:()=>{},waitingTimeoutRef:{current:null},clearTimeout:()=>{},
|
||||
setCurrentInteraction:()=>{}};
|
||||
return {receive:executable(handler,source,scope),writes,callbacks,state,t,scope};
|
||||
}
|
||||
let scenarios=0;
|
||||
for (const handler of handlers) for (const locale of ['en','sk']) for (const frame of ['raw','json']) {
|
||||
for (const [code,optedIn,kind] of [[6,true,'cancelled'],[6,false,'error'],[1,true,'error'],[0,true,'success'],[-1,true,'error']]) {
|
||||
const f=fixture(handler,locale,optedIn);
|
||||
const data=frame==='raw'?`\r\n[Script exited with code ${code}]\r\n`:JSON.stringify({type:'script_complete',exit_code:code});
|
||||
f.receive({data});
|
||||
assert.deepEqual(f.callbacks,[code]); assert.equal(f.state.closed,1);
|
||||
const rendered=f.writes.join('');
|
||||
if(kind==='cancelled') {
|
||||
assert.ok(rendered.includes(f.t('scriptTerminal.cancelled')));
|
||||
assert.ok(rendered.includes('\x1b[36m'));
|
||||
assert.ok(rendered.startsWith('\x1bc'),'cancel resets the obsolete dialog in the queued output');
|
||||
assert.ok(!rendered.includes('Script exited with code'));
|
||||
assert.ok(!rendered.includes('\x1b[31m'));
|
||||
assert.ok(!rendered.includes(f.t('scriptTerminal.completedSuccessfully')));
|
||||
} else {
|
||||
assert.ok(!rendered.includes('\x1bc'),'success/errors and non-opted-in scripts keep their logs');
|
||||
assert.ok(rendered.includes(kind==='success'?f.t('scriptTerminal.completedSuccessfully'):f.t('scriptTerminal.completedWithError',{code})));
|
||||
}
|
||||
// Worker sends both forms; neither a second frame nor a heartbeat may complete twice.
|
||||
f.receive({data:JSON.stringify({type:'script_complete',exit_code:code})});
|
||||
f.receive({data:'{"type":"pong"}'});
|
||||
assert.deepEqual(f.callbacks,[code]);assert.equal(f.state.closed,1);scenarios++;
|
||||
}
|
||||
for(const payload of [{type:'script_complete'},{type:'script_complete',exit_code:'invalid'}]) {
|
||||
const f=fixture(handler,locale);f.receive({data:JSON.stringify(payload)});
|
||||
assert.ok(f.writes.join('').includes('\x1b[31m'));
|
||||
assert.ok(!f.writes.join('').includes(f.t('scriptTerminal.cancelled')));scenarios++;
|
||||
}
|
||||
}
|
||||
for(const locale of Object.keys(catalogs)) assert.ok(translator(locale)('scriptTerminal.cancelled')!=='scriptTerminal.cancelled');
|
||||
// The native CLI and the explicitly opted-in caller share the documented code6 contract.
|
||||
const fs=require('node:fs');
|
||||
assert.match(fs.readFileSync('oci/src/proxmenux_oci/management.py','utf8'),/^EXIT_CANCELLED = 6 /m);
|
||||
assert.equal(completion.OCI_CANCELLED_EXIT_CODE,6);
|
||||
const parent=ts.createSourceFile('vms.tsx',read('components/virtual-machines.tsx'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX);
|
||||
let ociElement,reset,pendingRecoveryClick,registrationClick,registrationLabel;
|
||||
function find(node) {
|
||||
if(ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent)==='ScriptTerminalModal' &&
|
||||
node.attributes.properties.some(p=>p.name?.getText(parent)==='scriptPath' && p.initializer?.text?.endsWith('/oci/manage_instance.sh'))) ociElement=node;
|
||||
if(ts.isCallExpression(node) && node.expression.getText(parent)==='useEffect' && node.arguments[0]?.getText(parent).includes('ociCompletionCodeRef.current = undefined')) reset=node.arguments[0];
|
||||
if(ts.isJsxElement(node) && node.openingElement.tagName.getText(parent)==='Button') {
|
||||
const click=node.openingElement.attributes.properties.find(p=>p.name?.getText(parent)==='onClick')?.initializer?.expression;
|
||||
if(click?.getText(parent).includes('setOciAction(') && click.getText(parent).includes('action: "update"')) pendingRecoveryClick=click;
|
||||
if(click?.getText(parent).includes('setOciAction(') && click.getText(parent).includes('action: "recover"')) {
|
||||
registrationClick=click;
|
||||
registrationLabel=node.children.find(child=>ts.isJsxExpression(child)&&child.expression)?.expression;
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node,find);
|
||||
}
|
||||
find(parent);assert.ok(ociElement);assert.ok(reset);assert.ok(pendingRecoveryClick);assert.ok(registrationClick);assert.ok(registrationLabel);
|
||||
const attribute=name=>ociElement.attributes.properties.find(p=>p.name?.getText(parent)===name)?.initializer?.expression;
|
||||
assert.equal(attribute('cancelledExitCode').getText(parent),'OCI_CANCELLED_EXIT_CODE');
|
||||
(async()=>{
|
||||
for(const locale of ['en','sk']) {
|
||||
const scope={t:translator(locale),selectedVM:{vmid:110},applyBackup:false,applyBackupStorage:'',selectedBackupStorage:''};
|
||||
scope.setOciAction=value=>scope.ociAction=value;
|
||||
executable(registrationClick,parent,scope)();
|
||||
assert.equal(scope.ociAction.action,'recover');assert.equal(scope.ociAction.vmid,110);
|
||||
assert.equal(executable(registrationLabel,parent,scope),locale==='sk'?'Znova zaregistrovať':'Register again');
|
||||
assert.equal(executable(attribute('title'),parent,scope),locale==='sk'?
|
||||
'Zaregistrovať OCI aplikácie obnovené zo zálohy':'Register restored OCI applications');
|
||||
scope.selectedVM={vmid:111};
|
||||
assert.equal(executable(attribute('params'),parent,scope).VMID,'110','registration preserves the clicked target context');
|
||||
assert.equal(executable(attribute('params'),parent,scope).ACTION,'recover','registration retains its existing global CLI action');
|
||||
assert.equal(scope.t('vmLxc.ociUpdates.recover'),locale==='sk'?'Obnoviť':'Recover');
|
||||
}
|
||||
for(const locale of ['en','sk']) for(const pending of [false,true]) {
|
||||
const scope={t:translator(locale), selectedVM:{vmid:110}, ociInstance:{pending},
|
||||
applyBackup:false,applyBackupStorage:'',selectedBackupStorage:''};
|
||||
scope.setOciAction=value=>scope.ociAction=value;
|
||||
executable(pendingRecoveryClick,parent,scope)();
|
||||
assert.equal(scope.ociAction.vmid,110);
|
||||
assert.equal(scope.ociAction.action,'update','pending recovery retains the existing manage contract');
|
||||
// Background refresh or another selected guest must not rename this open operation.
|
||||
scope.ociInstance={pending:!pending};scope.selectedVM={vmid:999};
|
||||
const title=executable(attribute('title'),parent,scope);
|
||||
assert.equal(title,scope.t(pending?'vmLxc.ociUpdates.terminalTitleRecoverPrevious':'vmLxc.ociUpdates.terminalTitleUpdate'));
|
||||
assert.equal(title,pending?(locale==='sk'?'Obnoviť predchádzajúcu OCI inštaláciu':'Recover the previous OCI installation'):
|
||||
(locale==='sk'?'Aktualizovať OCI aplikáciu':'Update OCI application'));
|
||||
const params=executable(attribute('params'),parent,scope);
|
||||
assert.equal(params.ACTION,'update');assert.equal(params.VMID,'110');
|
||||
assert.equal(params.KEEP_BACKUP,'');
|
||||
}
|
||||
for(const action of ['modify','recover']) {
|
||||
const scope={t:translator('sk'),ociAction:{vmid:110,action,recoverPrevious:true}};
|
||||
assert.equal(executable(attribute('title'),parent,scope),scope.t(action==='recover'?
|
||||
'vmLxc.ociUpdates.terminalTitleRecover':'vmLxc.ociUpdates.terminalTitleModify'),
|
||||
'registration and editing retain their own titles');
|
||||
}
|
||||
for(const frame of ['raw','json']) {
|
||||
const f=fixture(handlers[0],'sk'),timers=[];
|
||||
const old={readyState:3,onclose:()=>f.callbacks.push(-1),onerror:()=>{},onmessage:()=>{},close(){this.onclose?.({})}};
|
||||
const ref={current:old};
|
||||
class Socket{static OPEN=1;constructor(){this.readyState=1}close(){this.onclose?.({})}}
|
||||
const scope={...f.scope,isOpen:true,isComplete:false,reconnectAttemptsRef:{current:0},reconnectTimeoutRef:{current:null},
|
||||
wsRef:ref,WebSocket:Socket,sessionIdRef:{current:'fixture'},paramsRef:{current:{}},keepAliveIntervalRef:{current:null},
|
||||
scriptPath:'/fixture/oci',setConnectionStatus:()=>{},getScriptWebSocketUrl:()=> 'ws://fixture',getTicketedWsUrl:async url=>url,
|
||||
setTimeout:fn=>{timers.push(fn);return 1},clearTimeout:()=>{}};
|
||||
executable(reconnectCallback,source,scope)();await timers[0]();
|
||||
assert.equal(old.onclose,null,'replaced connection cannot emit an unknown result');
|
||||
assert.equal(old.onmessage,null,'late result from replaced connection cannot win');
|
||||
ref.current.onmessage({data:frame==='raw'?'\r\n[Script exited with code 6]\r\n':JSON.stringify({type:'script_complete',exit_code:6})});
|
||||
assert.deepEqual(f.callbacks,[6]);
|
||||
}
|
||||
for(const action of ['update','modify']) for(const code of [6,0,1,undefined]) {
|
||||
const calls=[],row={vmid:110,action},saved=JSON.stringify(row),ref={current:undefined};
|
||||
const scope={...completion,ociAction:row,ociCompletionCodeRef:ref,
|
||||
setOciAction:value=>calls.push(['close',value]),fetchApi:(url,options)=>{calls.push(['fetch',url,options]);return Promise.resolve({});},
|
||||
invalidateLxcApps:vmid=>calls.push(['invalidate',vmid]),vmModalCacheRef:{current:{mountPoints:new Map()}},
|
||||
fetchOciInstance:vmid=>calls.push(['instance',vmid]),fetchMountPoints:vmid=>calls.push(['mounts',vmid]),mutate:()=>calls.push(['mutate'])};
|
||||
executable(attribute('onComplete'),parent,scope)(code);
|
||||
executable(attribute('onClose'),parent,scope)();
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(JSON.stringify(row),saved);
|
||||
if(code===6) assert.deepEqual(calls,[['close',null]],'cancel has zero registry/API/cache calls');
|
||||
else assert.equal(calls.filter(c=>c[0]==='fetch'&&c[2].method==='POST').length,1,'preserve refresh after non-cancel');
|
||||
executable(reset,parent,scope)();assert.equal(ref.current,undefined,'next modal cannot inherit cancellation');
|
||||
}
|
||||
console.log(`PASS: ${scenarios} real terminal frame scenarios; actual OCI close callbacks, code6 scoping, reset and zero cancel probes`);
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Actual Settings handlers and real fetchApi; all responses are inert fixtures.
|
||||
const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm')
|
||||
const {execFileSync}=require('node:child_process')
|
||||
const root=path.resolve(__dirname,'..'),app=path.join(root,'AppImage')
|
||||
const ts=require(path.join(app,'node_modules/typescript'))
|
||||
const catalogs=Object.fromEntries(['en','sk','de'].map(locale=>[locale,JSON.parse(fs.readFileSync(path.join(app,`messages/${locale}/common.json`)))]))
|
||||
// A locale without these keys must show the English name.
|
||||
catalogs.missing={}
|
||||
const translate=locale=>(key,params={})=>{
|
||||
const find=data=>key.split('.').reduce((item,name)=>item?.[name],data)
|
||||
return (find(catalogs[locale])??find(catalogs.en)??key).replace(/\{(\w+)\}/g,(all,key)=>params[key]??all)
|
||||
}
|
||||
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 compile=source=>ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,jsx:ts.JsxEmit.ReactJSX}}).outputText
|
||||
const source=process.argv.includes('--baseline')?execFileSync('git',['show','e826680c:AppImage/components/settings.tsx'],{cwd:root,encoding:'utf8'}):fs.readFileSync(path.join(app,'components/settings.tsx'),'utf8')
|
||||
const formatter={exports:{}};vm.runInNewContext(compile(fs.readFileSync(path.join(app,'lib/optimization-source-errors.ts'),'utf8')),formatter)
|
||||
const descriptionsPath=path.join(app,'lib/optimization-descriptions.ts')
|
||||
const descriptions={exports:{},require:name=>({default:require(path.resolve(app,'lib',name))})};if(fs.existsSync(descriptionsPath))vm.runInNewContext(compile(fs.readFileSync(descriptionsPath,'utf8')),descriptions)
|
||||
const tools=['one','two'].map(key=>({key,name:'Demo '+key,enabled:true,has_source:true,version:'1.0',available_version:'1.1'}))
|
||||
const sourceBody={success:true,function:'demo',script:'demo.sh',source:'demo() {\n echo "<tag>& $HOME"\n}\n',deprecated:false}
|
||||
function fixture(locale='sk',catalog=tools){
|
||||
let cursor=0,states=[],refs=[],effects=[],memo=[],tree,language=locale,reply=()=>sourceBody
|
||||
const calls=[],events={},timers=[],copies=[];let fallbackCopies=0
|
||||
const t=(key,params)=>translate(language)(key,params)
|
||||
const react={
|
||||
useState(value){const i=cursor++;if(!(i in states))states[i]=typeof value==='function'?value():value;return[states[i],v=>{states[i]=typeof v==='function'?v(states[i]):v}]},
|
||||
useRef(value){const i=cursor++;return refs[i]??=( {current:value})},
|
||||
useEffect(fn,deps){const i=cursor++,old=memo[i];if(!old||deps?.some((v,n)=>v!==old.deps?.[n]))effects.push(()=>{old?.cleanup?.();memo[i]={deps,cleanup:fn()}})},
|
||||
}
|
||||
const response=(body,status=200)=>({ok:status<400,status,statusText:'fixture',headers:{get:()=> 'application/json'},json:async()=>body})
|
||||
const fetch=async(url,options={})=>{
|
||||
const endpoint=String(url).replace(/^https?:\/\/[^/]+/,'');calls.push({endpoint,method:options.method||'GET'})
|
||||
assert.equal(options.method||'GET','GET','Source viewer may never mutate')
|
||||
if(endpoint.includes('/tool-source/')){const result=await reply(endpoint);if(result instanceof Error)throw result;return result?.http?response(result.body,result.http):response(result)}
|
||||
if(endpoint.endsWith('/installed-tools'))return response({success:true,installed_tools:catalog,updates_available_count:0})
|
||||
return response({categories:[],storages:[],interfaces:[],dismissed:[],success:true,candidates:[],configured:false})
|
||||
}
|
||||
const api={exports:{},fetch,process:{env:{}},console:{error(){}}};vm.runInNewContext(compile(fs.readFileSync(path.join(app,'lib/api-config.ts'),'utf8')),api)
|
||||
const add=(name,handler)=>{(events[name]??=new Set()).add(handler)},remove=(name,handler)=>events[name]?.delete(handler)
|
||||
const win={addEventListener:add,removeEventListener:remove,setTimeout:fn=>{timers.push(fn);return timers.length},isSecureContext:true}
|
||||
const doc={addEventListener:add,removeEventListener:remove,body:{appendChild(){},removeChild(){}},createElement:()=>({style:{},focus(){},select(){}}),execCommand:()=>{fallbackCopies++;return true}}
|
||||
const clipboard={writeText:async value=>copies.push(value)}
|
||||
const context={exports:{},window:win,document:doc,navigator:{clipboard},console:{error(){}},setTimeout:win.setTimeout,
|
||||
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('optimization-source-errors'))return formatter.exports
|
||||
if(name.includes('optimization-descriptions'))return descriptions.exports
|
||||
if(name.includes('api-config'))return api.exports
|
||||
if(name.includes('i18n/provider'))return{SUPPORTED_LANGUAGES:[],useI18n:()=>({t,language,setLanguage:value=>language=value})}
|
||||
if(name.includes('use-platform'))return{useIsPbs:()=>false}
|
||||
if(name.includes('format-network'))return{getNetworkUnit:()=> 'Bytes'}
|
||||
return new Proxy({},{get:(_,key)=>key})
|
||||
}}
|
||||
vm.runInNewContext(compile(source),context)
|
||||
const render=()=>{cursor=0;effects=[];tree=context.exports.Settings();for(const effect of effects)effect();return tree}
|
||||
const settle=async()=>{for(let i=0;i<24;i++)await Promise.resolve();render()}
|
||||
// Rows show the localized tool name when the catalog has one.
|
||||
const label=name=>{const fallback=catalog.find(tool=>tool.key===name)?.name||'Demo '+name;return descriptions.exports.optimizationToolName?descriptions.exports.optimizationToolName(name,fallback,t):fallback}
|
||||
const tool=name=>flatten(tree).find(node=>node.props?.title===t('settings.optimizations.sourceTitle')&&text(node).includes(label(name)))
|
||||
const dialog=()=>flatten(tree).find(node=>node.props?.role==='dialog')
|
||||
const close=()=>flatten(tree).find(node=>node.props?.['aria-label']===t('actions.close')).props.onClick()
|
||||
return{render,settle,tool,dialog,close,calls,copies,timers,tree:()=>tree,setReply:value=>reply=value,t,
|
||||
language:value=>{language=value;render()},escape:()=>events.keydown?.forEach(fn=>fn({key:'Escape'})),
|
||||
unmount:()=>memo.forEach(effect=>effect?.cleanup?.()),clipboard,win,fallbackCopies:()=>fallbackCopies}
|
||||
}
|
||||
async function main(){
|
||||
if(process.argv.includes('--baseline')){
|
||||
const f=fixture();f.render();await f.settle();f.setReply(()=>({http:404,body:{success:false,error:'Function not found in available scripts'}}));await f.tool('one').props.onClick();f.render();
|
||||
assert.ok(text(f.tree()).includes('Failed to load source code'));assert.ok(!text(f.tree()).includes(f.t('settings.optimizations.sourceFunctionMissing')))
|
||||
console.log('REPRODUCED baseline: real Settings + fetchApi 404 displays English Failed to load source code');return
|
||||
}
|
||||
for(const locale of ['en','sk','de']){
|
||||
const f=fixture(locale);f.render();await f.settle()
|
||||
for(const [reply,key,param]of [
|
||||
[{success:false},'sourceUnavailable'],[null,'sourceUnavailable'],[{success:'yes',source:'invalid'},'sourceUnavailable'],[{success:true,source:''},'sourceUnavailable'],[{success:true,source:' '},'sourceUnavailable'],[{success:true,source:17},'sourceUnavailable'],
|
||||
[{http:404,body:{success:false,error:'Function not found in available scripts'}},'sourceFunctionMissing'],
|
||||
[{http:404,body:{success:false,error:'Unknown tool: demo'}},'sourceUnknownTool',{tool:'demo'}],
|
||||
[{http:404,body:{success:false,error:'No function mapping for demo'}},'sourceNoMapping',{tool:'demo'}],
|
||||
[{success:false,error:'Source code not available'},'sourceUnavailable'],[new Error('network unreachable'),'sourceLoadFailed']]){
|
||||
f.setReply(()=>reply);await f.tool('one').props.onClick();f.render();assert.ok(text(f.dialog()).includes(f.t('settings.optimizations.'+key,param)),key)
|
||||
}
|
||||
f.setReply(()=>({http:500,body:{success:false,error:'PermissionError /opt/demo <unsafe>&'}}));await f.tool('one').props.onClick();f.render();assert.ok(text(f.dialog()).includes('PermissionError /opt/demo <unsafe>&'))
|
||||
f.language('sk');assert.ok(text(f.dialog()).includes(translate('sk')('settings.optimizations.sourceLoadFailed')))
|
||||
f.setReply(()=>sourceBody);await f.tool('one').props.onClick();f.render();
|
||||
const backdrop=flatten(f.tree()).find(node=>node.props?.className?.startsWith('fixed inset-0')&&flatten(node).some(child=>child.props?.role==='dialog'))
|
||||
assert.equal(backdrop.props.style.margin,0,'parent space-y must not offset the fixed backdrop')
|
||||
const pre=flatten(f.dialog()).find(node=>node.type==='pre');assert.ok(pre.props.dangerouslySetInnerHTML.__html.includes('<'));assert.ok(!pre.props.dangerouslySetInnerHTML.__html.includes('<tag>'))
|
||||
const copy=flatten(f.dialog()).find(node=>node.props?.title===f.t('actions.copyToClipboard'));await copy.props.onClick();f.render();assert.equal(f.copies.at(-1),sourceBody.source)
|
||||
f.win.isSecureContext=false;await copy.props.onClick();assert.equal(f.fallbackCopies(),1)
|
||||
const before=f.calls.length;f.close();f.render();assert.equal(f.dialog(),undefined);assert.equal(f.calls.length,before)
|
||||
}
|
||||
const names=catalogs.en.settings.optimizations.sourceToolNames
|
||||
const metadata=JSON.parse(execFileSync('python3',['-c',"import ast,json,sys;t=ast.parse(open(sys.argv[1]).read());n=next(n for n in t.body if isinstance(n,ast.Assign) and isinstance(n.targets[0],ast.Name) and n.targets[0].id=='TOOL_METADATA');print(json.dumps({k:v['name'] for k,v in ast.literal_eval(n.value).items()}))",path.join(app,'scripts/flask_proxmenux_routes.py')],{encoding:'utf8'}))
|
||||
assert.deepEqual(names,metadata,'source title names must match real backend metadata')
|
||||
for(const [key,name]of Object.entries(names)){
|
||||
assert.equal(formatter.exports.optimizationSourceName(key,name,translate('en')),name)
|
||||
assert.equal(formatter.exports.optimizationSourceName(key,name,translate('sk')),catalogs.sk.settings.optimizations.sourceToolNames[key])
|
||||
assert.equal(formatter.exports.optimizationSourceName(key,name,translate('missing')),name)
|
||||
}
|
||||
assert.equal(Object.keys(names).length,27)
|
||||
assert.equal(formatter.exports.optimizationSourceName('future','Future tool',translate('sk')),'Future tool')
|
||||
const known=fixture('sk',[{...tools[0],key:'time_sync',name:'Time Synchronization'}]);known.render();await known.settle();await known.tool('time_sync').props.onClick();known.render();assert.ok(text(known.dialog()).includes('Synchronizácia času'));known.language('en');assert.ok(text(known.dialog()).includes('Time Synchronization'))
|
||||
const f=fixture();f.render();await f.settle();let resolveOld;f.setReply(()=>new Promise(resolve=>resolveOld=resolve));const old=f.tool('one').props.onClick();f.render();assert.ok(flatten(f.dialog()).some(node=>node.props?.role==='status'))
|
||||
f.close();f.render();resolveOld(sourceBody);await old;f.render();assert.equal(f.dialog(),undefined,'late success never reopens closed viewer')
|
||||
let resolveStale;f.setReply(()=>new Promise(resolve=>resolveStale=resolve));const stale=f.tool('one').props.onClick();f.setReply(()=>({...sourceBody,source:'two() { :; }'}));await f.tool('two').props.onClick();f.render();resolveStale({http:404,body:{error:'Function not found in available scripts'}});await stale;f.render();assert.ok(text(f.dialog()).includes('Demo two'));assert.equal(flatten(f.dialog()).some(node=>node.props?.role==='alert'),false)
|
||||
const before=f.calls.length;f.escape();f.render();assert.equal(f.dialog(),undefined);assert.equal(f.calls.length,before)
|
||||
f.setReply(()=>sourceBody);await f.tool('one').props.onClick();f.render();let copyResolve;f.clipboard.writeText=()=>new Promise(resolve=>copyResolve=resolve);const copying=flatten(f.dialog()).find(node=>node.props?.title===f.t('actions.copyToClipboard')).props.onClick();f.close();f.render();copyResolve();await copying;f.render();assert.equal(f.dialog(),undefined)
|
||||
let unmountedResolve;f.setReply(()=>new Promise(resolve=>unmountedResolve=resolve));const unmounted=f.tool('one').props.onClick();f.render();const snapshot=text(f.tree());f.unmount();unmountedResolve(sourceBody);await unmounted;f.render();assert.equal(text(f.tree()),snapshot,'response after unmount cannot update modal state')
|
||||
assert.equal(f.calls.filter(call=>call.method!=='GET').length,0);assert.equal(f.calls.filter(call=>/scan|update|script-runner/.test(call.endpoint)).length,0)
|
||||
console.log('PASS source viewer: actual Settings/fetchApi, EN/SK/fallback, 404/500/network/malformed/empty, exact copy, late requests and close/Esc; mutations/scan/update 0')
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1})
|
||||
@@ -0,0 +1,154 @@
|
||||
// Shipped PBS code with only transport, hooks and UI primitives replaced.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX}}).outputText;
|
||||
const catalogs = Object.fromEntries(['en','sk'].map(l => [l, JSON.parse(fs.readFileSync(`AppImage/messages/${l}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], catalogs[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (m,k) => params[k] ?? m);
|
||||
};
|
||||
const library = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/pbs-messages.ts','utf8')), library);
|
||||
const messages = library.exports;
|
||||
const failure = code => Object.assign(new Error('RAW DIAGNOSTIC'), {body: {error:'RAW DIAGNOSTIC',code}});
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? n.type === 'Dialog' && !n.props.open ? [] : [n,...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale='sk', options={}) {
|
||||
let cursor=0, slots=[], effects=[], tree, apiError, reloadError;
|
||||
const calls=[], confirmations=[], reloads=[];
|
||||
const servers={local_available:false,servers:[{id:'saved',name:'Saved PBS',url:'https://saved.invalid:8007',token_id:'audit@pbs!monitor',has_token:true,fingerprint:''}],detected:[]};
|
||||
const status={name:'Fixture',version:'4.2.5',fetched_at:1700000000,services:{'proxmox-backup':'inactive'},datastores:[],tasks:[],sync:[],verify:[],prune:[],errors:{}};
|
||||
const t=translate(locale), loaded={};
|
||||
const jsx=(type,props) => typeof type === 'function' ? type(props) : ({type,props});
|
||||
const react={useState(initial){const i=cursor++;if(!(i in slots))slots[i]=typeof initial==='function'?initial():initial;return [slots[i],value=>{slots[i]=typeof value==='function'?value(slots[i]):value;}];},
|
||||
useEffect(fn,deps){const i=cursor++, old=slots[i];if(!old||deps.some((d,j)=>d!==old[j]))effects.push(fn);slots[i]=deps;}};
|
||||
function requireModule(name) {
|
||||
if(name==='react')return react;
|
||||
if(name==='react/jsx-runtime')return {jsx,jsxs:jsx,Fragment:'Fragment'};
|
||||
if(name==='swr')return {default:key=>({data:key?.startsWith('/api/pbs/status')?status:key?servers:undefined,error:options.loadError,
|
||||
isLoading:false,mutate:async()=>{reloads.push(key);if(reloadError)throw reloadError;}})};
|
||||
if(name.includes('i18n/provider'))return {useT:()=>t,useI18n:()=>({language:locale})};
|
||||
if(name.includes('pbs-messages'))return messages;
|
||||
if(name.includes('api-config'))return {fetchApi:async(path,opts)=>{calls.push({path,...opts});if(apiError)throw apiError;return {id:'new',success:true};}};
|
||||
if(name.endsWith('pbs-problem-message'))return load('AppImage/components/pbs-problem-message.tsx');
|
||||
if(name.endsWith('pbs-datastore-usage'))return load('AppImage/components/pbs-datastore-usage.tsx');
|
||||
if(name.includes('lib/utils'))return {formatStorage:n=>String(n)+' GB'};
|
||||
return new Proxy({}, {get:(_,key)=>key});
|
||||
}
|
||||
function load(path) {if(loaded[path])return loaded[path];const e={};loaded[path]=e;
|
||||
vm.runInNewContext(compile(fs.readFileSync(path,'utf8')), {exports:e,require:requireModule,Date,window:{confirm:message=>{confirmations.push(message);return options.confirm===true;}}});return e;}
|
||||
const panel=load('AppImage/components/pbs-panel.tsx').PBSPanel;
|
||||
return {t,servers,status,calls,confirmations,reloads,jsx,requireModule,
|
||||
render(props){cursor=0;effects=[];tree=panel(props);effects.forEach(fn=>fn());return tree;},
|
||||
nodes:()=>flatten(tree),text:()=>text(tree),
|
||||
button(label){const n=flatten(tree).find(n=>n.type==='Button'&&text(n)===label);assert.ok(n,label);return n;},
|
||||
input(field){const n=flatten(tree).find(n=>n.type==='Input'&&n.props.id==='pbs-'+field);assert.ok(n,field);return n;},
|
||||
select(label){const n=flatten(tree).find(n=>n.type==='Select'&&flatten(n).some(n=>n.props?.['aria-label']===label));assert.ok(n,label);return n;},
|
||||
fail:e=>{apiError=e;},failReload:e=>{reloadError=e;},load,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(locale, sourcePath='AppImage/components/system-overview.tsx') {
|
||||
const source=fs.readFileSync(sourcePath,'utf8'), ast=ts.createSourceFile('overview.tsx',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX), branches=[];
|
||||
function visit(n){if(ts.isConditionalExpression(n)&&n.condition.getText(ast)==='isPbs'&&n.whenTrue.getText(ast).includes('pbs.datastores'))branches.push(n.whenTrue.getText(ast));ts.forEachChild(n,visit);}visit(ast);
|
||||
assert.equal(branches.length,2,'overview summary and storage details both consume PBS');
|
||||
const f=fixture(locale);
|
||||
for(const branch of branches) {
|
||||
for(const state of ['partial','network','empty','loading']) {
|
||||
const pbsStatus=state==='loading'||state==='network'?undefined:{datastores:state==='empty'?[]:null,errors:{datastores:'RAW DIAGNOSTIC'},error_details:{datastores:{code:'pbs_token_rejected'}}};
|
||||
const context={isPbs:true,pbsStatus,pbsError:state==='network'?failure('pbs_connect_failed'):undefined,datastores:pbsStatus?.datastores,
|
||||
t:f.t,require:f.requireModule,pbsDiagnostic:messages.pbsDiagnostic,PBSProblemMessage:f.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage,
|
||||
DatastoreUsageRow:f.load('AppImage/components/pbs-datastore-usage.tsx').DatastoreUsageRow};
|
||||
for(const name of ['Card','CardHeader','CardTitle','CardContent','Database','Badge'])context[name]=name;
|
||||
vm.runInNewContext(compile('exports.result=('+branch+');'),{...context,exports:context});
|
||||
const rendered=text(context.result);
|
||||
if(state==='partial') {assert.ok(rendered.includes(f.t('pbs.errors.tokenRejected')));assert.ok(!rendered.includes(f.t('pbs.loading')));assert.ok(!flatten(context.result).some(n=>n.props?.className?.includes('animate-pulse')));}
|
||||
if(state==='network')assert.ok(rendered.includes(f.t('pbs.errors.connectFailed')));
|
||||
if(state==='empty')assert.ok(rendered.includes(f.t('pbs.noDatastores')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend=fs.readFileSync('AppImage/scripts/pbs_manager.py','utf8')+fs.readFileSync('AppImage/scripts/flask_pbs_routes.py','utf8');
|
||||
const codes=new Set([...backend.matchAll(/(?:code|rejected_code)="(pbs_\w+)"/g)].map(m=>m[1]));
|
||||
for(const locale of ['en','sk']) {
|
||||
const t=translate(locale);
|
||||
for(const code of codes) {
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
assert.notEqual(messages.pbsErrorText(err,t),t('pbs.loadFailed'),code);
|
||||
assert.ok(!messages.pbsErrorText(err,t).includes('RAW'));
|
||||
}
|
||||
for(const err of [null,undefined,'raw',new Error('raw'),{body:null},{code:'constructor'},{code:'__proto__'},{code:'future'},
|
||||
...[null,0,-1,1.2,'32',Infinity,NaN,2**54].map(count=>({code:'pbs_connection_limit',params:{count}}))]) {
|
||||
assert.equal(messages.pbsErrorText(err,t),t('pbs.loadFailed'));
|
||||
}
|
||||
assert.equal(messages.pbsResultText('OK',t),t('pbs.states.ok'));
|
||||
assert.equal(messages.pbsResultText('WARNINGS: 2',t),t('pbs.states.warnings',{count:2}));
|
||||
assert.equal(messages.pbsResultText(undefined,t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('unknown',t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('TASK ERROR: raw',t),t('pbs.states.error'));
|
||||
for(const n of [-1,NaN,Infinity])assert.equal(messages.formatPBSDuration(n,t),'—');
|
||||
for(const [n,label] of [[0,'0 s'],[59,'59 s'],[60,'1 min 0 s'],[3599,'59 min 59 s'],[3600,'1 h 0 min'],[86399,'23 h 59 min'],[86400,'1 d 0 h']])assert.equal(messages.formatPBSDuration(n,t),label);
|
||||
assert.equal(messages.pbsTaskType('garbage_collection',t),t('pbs.taskTypes.garbageCollection'));
|
||||
assert.equal(messages.pbsTaskType('future',t),t('pbs.taskTypes.other',{type:'future'}));
|
||||
assert.equal(messages.pbsServiceState('constructor',t),t('pbs.serviceStates.unknown'));
|
||||
assert.equal(t('pbs.url'),locale==='sk'?'Adresa HTTPS':'HTTPS address');
|
||||
const diagnosticFixture=fixture(locale),problem=diagnosticFixture.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage;
|
||||
for(const code of codes){
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
const rendered=problem({problem:err,raw:'RAW DIAGNOSTIC'});
|
||||
assert.ok(!text(rendered).includes('RAW DIAGNOSTIC'),'Known '+code+' must not repeat an English application message');
|
||||
assert.ok(!flatten(rendered).some(n=>n.type==='details'));
|
||||
}
|
||||
for(const code of ['future','__proto__','constructor']){
|
||||
const rendered=problem({problem:failure(code),raw:'RAW <img> DIAGNOSTIC'});
|
||||
assert.ok(text(rendered).includes('RAW <img> DIAGNOSTIC'));assert.ok(flatten(rendered).some(n=>n.type==='details'));
|
||||
}
|
||||
const unknown=fixture(locale);unknown.status.tasks=[{upid:'unknown',endtime:1700000000,status:'unknown'}];unknown.status.sync=[{id:'unknown','last-run-state':'unknown'}];
|
||||
unknown.render();unknown.render();
|
||||
const unknownBadges=unknown.nodes().filter(n=>n.type==='Badge'&&text(n)===t('pbs.states.unknown'));
|
||||
assert.equal(unknownBadges.length,2);assert.ok(unknownBadges.every(n=>!n.props.className.includes('red-')));
|
||||
unknown.status.tasks=[{upid:'warn',endtime:1700000000,status:'WARNINGS: 3'},{upid:'raw',endtime:1700000000,status:'RAW EXTERNAL TASK ERROR'}];unknown.status.sync=[];unknown.render();
|
||||
const taskDetails=unknown.nodes().filter(n=>n.type==='details');assert.equal(taskDetails.length,1);assert.ok(text(taskDetails[0]).includes('RAW EXTERNAL TASK ERROR'));
|
||||
const f=fixture(locale);f.render();f.render();f.button(t('pbs.manage')).props.onClick();f.render();
|
||||
assert.equal(f.input('url').props['aria-describedby'],'pbs-url-help');
|
||||
assert.ok(f.text().includes(t('pbs.urlHelp')));assert.ok(f.text().includes(t('pbs.usernameHelp')));
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();
|
||||
f.input('password').props.onChange({target:{value:'fake-one-time-password'}});f.render();
|
||||
f.button(t('pbs.cancel')).props.onClick();f.render();assert.equal(f.calls.length,0);
|
||||
f.button(t('pbs.manage')).props.onClick();f.render();assert.equal(f.input('password').props.value,'');
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();f.input('url').props.onChange({target:{value:'http://bad.invalid'}});f.render();
|
||||
f.fail(failure('pbs_invalid_url'));await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.errors.invalidUrl')));assert.equal(f.input('url').props.value,'http://bad.invalid');
|
||||
assert.equal(f.calls[0].path,'/api/pbs/servers/login');assert.ok(!Object.hasOwn(JSON.parse(f.calls[0].body),'token_secret'));
|
||||
f.button(t('pbs.methodToken')).props.onClick();f.render();await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.tokenSecretHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretKeepHelp')));
|
||||
assert.equal(f.input('token_secret').props['aria-describedby'],'pbs-token_secret-help');
|
||||
assert.equal(f.calls[1].path,'/api/pbs/servers');assert.ok(!Object.hasOwn(JSON.parse(f.calls[1].body),'password'));
|
||||
f.select(t('pbs.editConnection')).props.onValueChange('saved');f.render();await f.button(t('pbs.remove')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.tokenSecretKeepHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretHelp')));
|
||||
assert.deepEqual(f.confirmations,[t('pbs.removeConfirm')]);assert.equal(f.calls.length,2,'Declined remove has no DELETE');
|
||||
const remove=fixture(locale,{confirm:true});remove.render();remove.render();remove.button(t('pbs.manage')).props.onClick();remove.render();remove.select(t('pbs.editConnection')).props.onValueChange('saved');remove.render();
|
||||
await remove.button(t('pbs.remove')).props.onClick();remove.render();assert.equal(remove.calls.length,1);assert.equal(remove.calls[0].method,'DELETE');
|
||||
const failedRemove=fixture(locale,{confirm:true});failedRemove.render();failedRemove.render();failedRemove.button(t('pbs.manage')).props.onClick();failedRemove.render();failedRemove.select(t('pbs.editConnection')).props.onValueChange('saved');failedRemove.render();failedRemove.fail(new Error('network failure'));
|
||||
await failedRemove.button(t('pbs.remove')).props.onClick();failedRemove.render();assert.ok(failedRemove.text().includes(t('pbs.removeFailed')));assert.equal(failedRemove.calls.length,1);
|
||||
const failedReload=fixture(locale);failedReload.render();failedReload.render();failedReload.button(t('pbs.manage')).props.onClick();failedReload.render();failedReload.failReload(new Error('raw reload'));
|
||||
for(const [field,value] of Object.entries({name:'Valid',url:'https://fixture.invalid',username:'root@pam',password:'fake'})){failedReload.input(field).props.onChange({target:{value}});failedReload.render();}
|
||||
assert.equal(failedReload.button(t('pbs.save')).props.disabled,false);
|
||||
await failedReload.button(t('pbs.save')).props.onClick();failedReload.render();assert.equal(failedReload.calls.length,1,'No automatic mutation retry after refresh fails');assert.ok(failedReload.text().includes(t('pbs.savedRefreshFailed')));
|
||||
const removeReload=fixture(locale,{confirm:true});removeReload.render();removeReload.render();removeReload.button(t('pbs.manage')).props.onClick();removeReload.render();removeReload.select(t('pbs.editConnection')).props.onValueChange('saved');removeReload.render();removeReload.failReload(new Error('network'));
|
||||
await removeReload.button(t('pbs.remove')).props.onClick();removeReload.render();assert.equal(removeReload.calls.length,1);assert.ok(removeReload.text().includes(t('pbs.removedRefreshFailed')));
|
||||
const store=f.load('AppImage/components/pbs-datastore-usage.tsx').PBSDatastoreCard({store:{name:'Offline',total:null,used:null,error:'RAW DIAGNOSTIC',error_code:'pbs_datastore_offline'}});
|
||||
assert.ok(text(store).includes(t('pbs.errors.datastoreOffline')));assert.ok(!text(store).includes('0 GB'));
|
||||
overview(locale);
|
||||
}
|
||||
console.log('PASS: all structured PBS error codes, EN/SK/unknown diagnostics, forms/login/token/remove/cancel, no automatic retry, task/service/duration states and both real overview branches');
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1});
|
||||
module.exports={fixture,overview};
|
||||
Reference in New Issue
Block a user