mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts: # .github/scripts/run_offline_qualified.py # lang/sk.json
This commit is contained in:
@@ -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,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