fix(backup): use complete messages for archives and destinations

This commit is contained in:
martino
2026-09-21 19:49:43 +02:00
parent 704eb3f9fb
commit 6a7ffad9b8
6 changed files with 185 additions and 3 deletions
+91
View File
@@ -0,0 +1,91 @@
// Offline JSX seam, following the TypeScript compiler harness in adjacent tests.
// No component imports, React effects, API calls or host-management execution.
// Run from the repository root: node tests/test_backup_archives_empty.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const app = path.resolve(__dirname, '../AppImage');
const ts = createRequire(path.join(app, 'package.json'))('typescript');
const read = file => fs.readFileSync(path.join(app, file), 'utf8');
const parse = file => ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const compile = text => ts.transpileModule(text, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.React },
}).outputText;
const printer = ts.createPrinter();
const provider = parse('lib/i18n/provider.tsx');
const pure = provider.statements.filter(node => ts.isFunctionDeclaration(node) &&
['getMessage', 'interpolate'].includes(node.name?.text));
assert.equal(pure.length, 2);
let lookup;
function visitLookup(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(provider) === 't' &&
node.initializer && ts.isCallExpression(node.initializer) &&
node.initializer.expression.getText(provider) === 'useCallback') lookup = node.initializer.arguments[0];
ts.forEachChild(node, visitLookup);
}
visitLookup(provider);
assert.ok(lookup, 'real provider lookup callback');
const lookupCode = pure.map(node => printer.printNode(ts.EmitHint.Unspecified, node, provider)).join('\n') +
'\nconst t = ' + printer.printNode(ts.EmitHint.Expression, lookup, provider) + ';';
const catalogs = Object.fromEntries(fs.readdirSync(path.join(app, 'messages')).filter(locale =>
fs.existsSync(path.join(app, 'messages', locale, 'common.json'))).map(locale =>
[locale, JSON.parse(read(`messages/${locale}/common.json`))]));
// Compile only trusted, checked-out source; no catalog/input strings enter code.
const translator = (language, messages = catalogs) =>
new Function('MESSAGE_CATALOG', 'language', compile(lookupCode) + '\nreturn t;')(messages, language);
const source = parse('components/host-backup.tsx');
let branch;
function visitBranch(node) {
if (ts.isConditionalExpression(node) && node.condition.getText(source) === 'archivesErr && remoteArchivesErr') branch = node;
ts.forEachChild(node, visitBranch);
}
visitBranch(source);
assert.ok(branch, 'real archives loading/error/empty decision');
// Keep the actual guards and JSX messages. Only replace the nonempty archive
// list with a sentinel: rendering its controls is outside this message test.
const transformed = ts.transform(branch, [context => root => {
const visit = node => {
if (ts.isConditionalExpression(node) && node.condition.getText(source) === 'unifiedArchives.length === 0') {
return ts.factory.updateConditionalExpression(node, node.condition, node.questionToken,
node.whenTrue, node.colonToken, ts.factory.createStringLiteral('NONEMPTY_ARCHIVES'));
}
return ts.visitEachChild(node, visit, context);
};
return ts.visitNode(root, visit);
}]);
const expression = printer.printNode(ts.EmitHint.Expression, transformed.transformed[0], source);
transformed.dispose();
const render = new Function('React', 'Loader2', 't', 'archivesErr', 'remoteArchivesErr',
'archivesResp', 'remoteArchivesResp', 'unifiedArchives', compile(`const result = ${expression};`) + '\nreturn result;');
// Text-only JSX sink: TypeScript performs the real JSX whitespace conversion.
const React = { createElement: (tag, props, ...children) => children.flat(Infinity).filter(x => x != null && x !== false).join('') };
const text = (locale = 'en', overrides = {}, messages = catalogs) => {
const state = { archivesErr: null, remoteArchivesErr: null, archivesResp: { archives: [] },
remoteArchivesResp: { snapshots: [] }, unifiedArchives: [], ...overrides };
return render(React, 'spinner', translator(locale, messages), state.archivesErr, state.remoteArchivesErr,
state.archivesResp, state.remoteArchivesResp, state.unifiedArchives);
};
assert.equal(text(), 'No backup archives found.');
assert.equal(text('it'), 'No backup archives found.', 'Italian catalog uses English fallback');
// Keep fallback coverage even after all shipped locales gain the new key.
assert.equal(text('missing', {}, { ...catalogs, missing: {} }), 'No backup archives found.');
for (const locale of Object.keys(catalogs)) {
// Existing catalogs intentionally have no new key; actual provider falls back.
if (catalogs[locale].backup?.archives?.emptyMessage === undefined) {
assert.equal(text(locale), 'No backup archives found.', `${locale}: missing-key fallback`);
}
}
for (const count of [1, 3]) assert.equal(text('en', { unifiedArchives: Array(count).fill({}) }), 'NONEMPTY_ARCHIVES');
assert.equal(text('en', { archivesResp: undefined, remoteArchivesResp: undefined }), translator('en')('backup.common.loading'));
assert.equal(text('en', { archivesErr: true, remoteArchivesErr: true }), translator('en')('backup.archives.loadFailed'));
// Preserve existing partial-source semantics; no claim that every source was queried.
assert.equal(text('en', { archivesErr: true, archivesResp: undefined }), 'No backup archives found.');
assert.equal(text('en', { remoteArchivesErr: true, remoteArchivesResp: undefined }), 'No backup archives found.');
assert.equal(translator('en')('backup.manual.run'), 'Run');
assert.ok(read('components/host-backup.tsx').includes('{t("backup.manual.run")}'), 'manual action remains');
console.log('PASS: actual archives JSX seam, zero/one/multiple, loading/errors, locale fallback, Run unchanged');
// Export only test seams for separate, unapplied linguistic proposal checks.
module.exports = { text, translator, catalogs };
@@ -0,0 +1,56 @@
// Offline actual JSX/provider seams; no component/backend imports or effects.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const { translator, catalogs } = require('./test_backup_archives_empty.cjs');
const app = path.resolve(__dirname, '../AppImage');
const ts = createRequire(path.join(app, 'package.json'))('typescript');
const source = ts.createSourceFile('host-backup.tsx', fs.readFileSync(path.join(app, 'components/host-backup.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const printer = ts.createPrinter();
const React = { createElement: (tag, props, ...children) => children.flat(Infinity).filter(x => x != null && x !== false).join('') };
function find(predicate) {
const found = [];
function visit(node) { if (predicate(node)) found.push(node); ts.forEachChild(node, visit); }
visit(source);
assert.equal(found.length, 1, 'unique actual message seam');
return found[0];
}
function render(node, locale, backups = 1, messages = catalogs) {
const code = ts.transpileModule(`const result = ${printer.printNode(ts.EmitHint.Expression, node, source)};`, {
compilerOptions: { target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.React },
}).outputText;
// Only trusted checked-out AST enters executable code, never catalog values.
return new Function('React', 't', 'backups', code + '\nreturn result;')(React, translator(locale, messages), backups);
}
const kept = find(node => ts.isBinaryExpression(node) && node.left.getText(source) === 'backups > 0');
const keptMessage = 'Existing backup archives are not deleted.';
for (const count of [1, 3]) assert.equal(render(kept, 'it', count), keptMessage, 'Italian catalog uses English fallback');
assert.equal(render(kept, 'en', 0), false);
for (const locale of Object.keys(catalogs)) {
if (catalogs[locale].backup?.destinations?.backupsKeptMessage !== undefined && locale !== 'en') continue;
for (const count of [1, 3]) assert.equal(render(kept, locale, count), keptMessage, `${locale}: kept whole-message fallback`);
}
console.log('PASS: destination removal JSX guard 0/1/N and whole-message locale fallback');
// Select the actual help paragraph, keeping its literal paths/JSX whitespace.
const help = find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'p' &&
/backup\.destinations\.localAttachHelp(?:Before|Message)/.test(node.getText(source)));
const helpMessage = 'Mount USB drives first. Leave blank to use the PVE storage path + /dump (fallback: /var/lib/vz/dump).';
assert.equal(render(help, 'it'), helpMessage, 'Italian catalog uses English fallback');
for (const locale of Object.keys(catalogs)) {
if (catalogs[locale].backup?.destinations?.localAttachHelpMessage !== undefined && locale !== 'en') continue;
assert.equal(render(help, locale), helpMessage, `${locale}: local attach help fallback`);
}
// Missing-key fallback must remain covered after shipped catalogs are complete.
const missing = { ...catalogs, missing: {} };
assert.equal(render(kept, 'missing', 1, missing), keptMessage);
assert.equal(render(help, 'missing', 1, missing), helpMessage);
// Synthetic locale proves complete messages are translatable, not hardcoded.
const synthetic = { ...catalogs, fixture: { backup: { destinations: {
backupsKeptMessage: 'Fixture kept.', localAttachHelpMessage: 'Fixture help.',
} } } };
assert.equal(render(kept, 'fixture', 1, synthetic), 'Fixture kept.');
assert.equal(render(help, 'fixture', 1, synthetic), 'Fixture help.');
assert.equal(catalogs.en.backup.destinations.kept, 'kept');
assert.equal(catalogs.en.backup.destinations.localAttachHelpMiddle, 'USB drives');
console.log('PASS: local attach JSX whole-message fallback and literal paths');